Compare commits
194 Commits
76-impleme
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 506ac612bc | |||
| 30edc307af | |||
| 20adae3675 | |||
| cd7d529b86 | |||
| 15504ecba3 | |||
| e98ef96b2e | |||
| 98eb1083eb | |||
| 3aa2a2583d | |||
| 5b441b3698 | |||
| e45a94f260 | |||
| c088f1003b | |||
| e0e4092218 | |||
| 893274ca6a | |||
| 0601d9e6a0 | |||
| 91f5f4ba7c | |||
| 4764ee6af0 | |||
| 44d2da45d1 | |||
| fe649c81eb | |||
| 946f8dd342 | |||
| 690b2e1559 | |||
| 78f20fa925 | |||
| 90d993736d | |||
| 4d79b842f5 | |||
| 5cd2e0cc59 | |||
| 12951c96cb | |||
| da1a57ba70 | |||
| b1420cfa7e | |||
| ecc9cdd747 | |||
| 471121aa0f | |||
| f3cc9ec4f5 | |||
| 4e35498be7 | |||
| 91bdb7d0e3 | |||
| 298bf229f8 | |||
| 19cdbec42b | |||
| 8d2dd60cfb | |||
| ee10daf957 | |||
| 715bef163c | |||
| 6c199e38a8 | |||
| 413443a162 | |||
| 8c341e91e0 | |||
| 712e311006 | |||
| c2f1a67a70 | |||
| f34f78aaea | |||
| 37fbc3ef45 | |||
| ddab9b96ff | |||
| 22322e5f0a | |||
| a9db9b5d5c | |||
| 645fe1055b | |||
| b202189ad6 | |||
| 1d98e8450e | |||
| fb01406a29 | |||
| 2ff71b0dd1 | |||
| dbcc882a18 | |||
| 545eaef74a | |||
| b8c3f17510 | |||
| 83671e2b22 | |||
| c8c5aa28c6 | |||
| 78cf60af4a | |||
| 4ffdd35b21 | |||
| 8d9ff3ac62 | |||
| 47708d050e | |||
| f334054b5e | |||
| 34b2ab05d4 | |||
| 0798773bcb | |||
| a2f76703ea | |||
| 0af54cd48f | |||
| 1dfdfeff1e | |||
| 1df69b3a73 | |||
| 5f3ca479aa | |||
| e1a389101f | |||
| 8621f10721 | |||
| f8e7670b3f | |||
| 397eb2b588 | |||
| b2ebd3df9c | |||
| f06e778b80 | |||
| 1511b3c118 | |||
| f76126c951 | |||
| 4523badea9 | |||
| 05fc03c032 | |||
| a89d3c7ae3 | |||
| 6278d695fa | |||
| b74a0d5d64 | |||
| 2df8f389b3 | |||
| f9ed8f16ac | |||
| d6ad90ee7a | |||
| 0cf2a4d2e7 | |||
| 2fbc13202d | |||
| bf6a2e4667 | |||
| 91584fc2ab | |||
| 42c71815e6 | |||
| e4151ed914 | |||
| f95263eec8 | |||
| b5690d8983 | |||
| 076f1fd1e4 | |||
| 71ac18cb8d | |||
| 3f267be154 | |||
| d302e8a2cb | |||
| 7463cb27f1 | |||
| 1866224b5b | |||
| 4096c0ce44 | |||
| 97feec6d3f | |||
| e03e962ab9 | |||
| 6a31609960 | |||
| 7c5aeda4a9 | |||
| ac81806b26 | |||
| 88a393c1ad | |||
| 5e8a3c84a6 | |||
| 4b37f3a121 | |||
| e8f4e7b9ee | |||
| 9af50ddfe9 | |||
| b9fefcc6a9 | |||
| 04e8354e05 | |||
| bc7ba7d507 | |||
| 6cf61d1cdb | |||
| 7497c1d004 | |||
| a3bfa4894e | |||
| 992a2b6149 | |||
| 5b1c5897fc | |||
| c6f8e5642e | |||
| dac22da0b5 | |||
| 2854912e07 | |||
| 1d366bd63d | |||
| fd59f6cbd9 | |||
| 90041555a9 | |||
| 747477cff4 | |||
| 5eb40ff8a5 | |||
| 15315f826e | |||
| 42cd34e755 | |||
| 57d0171d68 | |||
| 6df4678acc | |||
| 75537a9c3a | |||
| 7a0e303080 | |||
| e05519f651 | |||
| 99999450b5 | |||
| 01c55d52ec | |||
| b6b79e1345 | |||
| 13bfc853e3 | |||
| e0b00d1887 | |||
| 83e3414c8e | |||
| ea943206c0 | |||
| cb23379723 | |||
| 7ac8232878 | |||
| e707c9cfd8 | |||
| 809c0b9270 | |||
| c43201b867 | |||
| 53e120cd1e | |||
| 1783d5f6f7 | |||
| 41bea7b693 | |||
| 09f5fa55df | |||
| 589f630f23 | |||
| 5bcb1b3ccd | |||
| fb3be46ef2 | |||
| ca5c7c618a | |||
| 20c97de94d | |||
| e6793572bf | |||
| 4481496fda | |||
| c9cbde7a78 | |||
| a3854aab6d | |||
| 864097154e | |||
| 7a0937c04e | |||
| ef20117f3c | |||
| 91517669f0 | |||
| d92db84738 | |||
| bd1ae07662 | |||
| c946647483 | |||
| 584dd44aad | |||
| cba91dae55 | |||
| 2a80a049bd | |||
| 63ed267ded | |||
| 321631e6c9 | |||
| b535868897 | |||
| 8ed23544c5 | |||
| 4b4715cdb2 | |||
| 72f596821a | |||
| e29e772f6b | |||
| 7300a808a7 | |||
| 9a959d388f | |||
| 91ac397c75 | |||
| 4db517d4c0 | |||
| a2f074b150 | |||
| 9040ed7175 | |||
| 415b461308 | |||
| 33b0de44fb | |||
| 513c8bb127 | |||
| dc50f98a6d | |||
| 67a4b3fc8e | |||
| 155ab2b4d2 | |||
| 5ed15ee534 | |||
| 7ed780a459 | |||
| 9268ad5181 | |||
| 7d886975b9 | |||
| db263122a1 | |||
| 74a55b3f57 | |||
| 9a2b2f65b9 |
@@ -21,3 +21,4 @@ RATE_LIMIT_DELAY_MULTIPLIER_MS=
|
||||
|
||||
# Redis connection
|
||||
REDIS_URL=
|
||||
REDIS_USE_TLS=
|
||||
|
||||
47
.github/workflows/deploy-coolify.yml
vendored
Normal file
47
.github/workflows/deploy-coolify.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: "Deploy to Coolify"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: gitea.bchen.dev
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PAT }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: gitea.bchen.dev/${{ github.actor }}/project-inter-server:latest
|
||||
|
||||
- name: Set up Tailscale
|
||||
uses: tailscale/github-action@v4
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:ci
|
||||
use-cache: false
|
||||
tailscaled-args: --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055
|
||||
|
||||
- name: Deploy to Coolify
|
||||
run: |
|
||||
curl --request POST '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'
|
||||
103
AGENTS.md
Normal file
103
AGENTS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to all agents when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Core Development
|
||||
|
||||
```bash
|
||||
# Start development server with hot reloading
|
||||
docker compose run dev
|
||||
|
||||
# Run comprehensive test suite
|
||||
docker compose run test
|
||||
|
||||
# Generate GraphQL TypeScript types
|
||||
npm run generate
|
||||
|
||||
# Build for development (install, codegen, tsc)
|
||||
npm run build:dev
|
||||
```
|
||||
|
||||
Only use Docker Compose for running tests, and only use `docker compose run test`
|
||||
to run tests; don't try to run tests for individual files.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Project Inter Server is a GraphQL-based backend for college transit tracking with real-time shuttle data, parking availability, and push notifications.
|
||||
|
||||
- InterchangeSystem: Central orchestrator for shuttles, parking, and notifications
|
||||
- Use `InterchangeSystem.build()` in production
|
||||
- Use `InterchangeSystem.buildForTesting()` in tests
|
||||
|
||||
- Repository Pattern (data access abstraction)
|
||||
- Shuttle: `UnoptimizedInMemoryShuttleRepository`
|
||||
- Parking: `InMemoryParkingRepository`
|
||||
- Notifications: `RedisNotificationRepository` (prod) / `InMemoryNotificationRepository` (test)
|
||||
|
||||
- Data Loaders (external API integration)
|
||||
- `ApiBasedShuttleRepositoryLoader` – Passio GO! API
|
||||
- `ChapmanApiBasedParkingRepositoryLoader` – Parking data
|
||||
- `TimedApiBasedRepositoryLoader` – Periodic refresh wrapper
|
||||
|
||||
- Notification System
|
||||
- `ETANotificationScheduler` – Shuttle arrival notifications
|
||||
- `AppleNotificationSender` – APNS integration
|
||||
|
||||
## GraphQL
|
||||
|
||||
- Schema definition: `schema.graphqls`
|
||||
- Generated types: `src/generated/`
|
||||
- Resolvers: `src/resolvers/`
|
||||
- Resolver merge: `src/MergedResolvers.ts`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `src/entities/` – Core business logic
|
||||
- `src/repositories/` – Data access layer
|
||||
- `src/loaders/` – External API integrations
|
||||
- `src/notifications/` – Push notification system
|
||||
- `src/resolvers/` – GraphQL resolvers and tests
|
||||
- `testHelpers/` – Test utilities and mock data
|
||||
|
||||
## Docker Services
|
||||
|
||||
- `dev` – Development server with hot reload
|
||||
- `test` – Unit/integration tests
|
||||
- `redis` – Persistent Redis
|
||||
- `redis-no-persistence` – Ephemeral Redis for tests
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- Prefer `buildForTesting()` to construct systems in tests.
|
||||
- Mock external APIs using JSON snapshots under `testHelpers/jsonSnapshots`.
|
||||
- Use in-memory repositories for speed where possible.
|
||||
- When adding features that affect API output, add focused resolver tests in the corresponding `src/resolvers/__tests__` file.
|
||||
- Separate unit tests from integration tests where practical to keep feedback fast and failures well-scoped.
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### General Guidelines
|
||||
|
||||
- Use test-driven development where possible. Write tests before implementation and run them before and after changes.
|
||||
- Use Docker Compose for tests. Run `docker compose run test` so you can see full output.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- Name pull requests after their branch name.
|
||||
|
||||
### Code Style
|
||||
|
||||
- Prefer arrow functions, especially within classes.
|
||||
- Keep changes minimal and focused; avoid unrelated refactors. Mention incidental issues separately.
|
||||
- Respect existing interfaces and types; use non-destructive edits.
|
||||
|
||||
### Agent Tips
|
||||
|
||||
- Write or update tests alongside changes; validate with `docker compose run test`.
|
||||
- When touching GraphQL resolvers, co-locate new tests in `src/resolvers/__tests__`.
|
||||
|
||||
## Multi-tenant Support
|
||||
|
||||
Currently supports Chapman University (Passio System ID: `263`). Each university system uses isolated repositories and configuration. New systems should be added via `InterchangeSystem` configuration and appropriate loaders.
|
||||
102
CLAUDE.md
102
CLAUDE.md
@@ -1,101 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Core Development
|
||||
```bash
|
||||
# Start development server with hot reloading
|
||||
docker compose run dev
|
||||
|
||||
# Run comprehensive test suite
|
||||
docker compose run test
|
||||
|
||||
|
||||
# Generate GraphQL TypeScript types
|
||||
npm run generate
|
||||
|
||||
# Build for development
|
||||
npm run build:dev
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run specific test file
|
||||
npm test -- --testPathPattern=<test-file-name>
|
||||
|
||||
# Run tests with coverage
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Project Inter Server** is a GraphQL-based backend for college transit tracking with real-time shuttle data, parking availability, and push notifications.
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **InterchangeSystem** - Central orchestrator managing shuttles, parking, and notifications
|
||||
- Use `InterchangeSystem.build()` for production
|
||||
- Use `InterchangeSystem.buildForTesting()` for tests
|
||||
|
||||
2. **Repository Pattern** - Data access abstraction
|
||||
- Shuttle: `UnoptimizedInMemoryShuttleRepository`
|
||||
- Parking: `InMemoryParkingRepository`
|
||||
- Notifications: `RedisNotificationRepository` (prod) / `InMemoryNotificationRepository` (test)
|
||||
|
||||
3. **Data Loaders** - External API integration
|
||||
- `ApiBasedShuttleRepositoryLoader` - Passio GO! API integration
|
||||
- `ChapmanApiBasedParkingRepositoryLoader` - University parking data
|
||||
- `TimedApiBasedRepositoryLoader` - Periodic data refresh
|
||||
|
||||
4. **Notification System**
|
||||
- `ETANotificationScheduler` - Manages shuttle arrival notifications
|
||||
- `AppleNotificationSender` - APNS integration
|
||||
- Default threshold: 180 seconds
|
||||
|
||||
### GraphQL Schema
|
||||
- Schema definition: `schema.graphqls`
|
||||
- Generated types: `src/generated/`
|
||||
- Resolvers: `src/resolvers/`
|
||||
- Combined in: `src/MergedResolvers.ts`
|
||||
|
||||
### Directory Structure
|
||||
- `src/entities/` - Core business logic
|
||||
- `src/repositories/` - Data access layer
|
||||
- `src/loaders/` - External API integrations
|
||||
- `src/notifications/` - Push notification system
|
||||
- `test/` - Comprehensive test suite with mock data
|
||||
|
||||
### Multi-tenant Support
|
||||
Currently supports Chapman University (Passio System ID: "263"). Each university system has:
|
||||
- System-specific configurations
|
||||
- Isolated data repositories
|
||||
- Custom API integrations
|
||||
|
||||
### Docker Services
|
||||
- `dev` - Development with hot reload
|
||||
- `test` - Unit/integration testing
|
||||
- `redis` - Persistent Redis
|
||||
- `redis-no-persistence` - Ephemeral Redis for tests
|
||||
|
||||
### Testing Patterns
|
||||
- Use `buildForTesting()` for InterchangeSystem in tests
|
||||
- Mock external APIs with JSON snapshots in test data
|
||||
- Separate unit tests from integration tests
|
||||
- Use in-memory repositories for faster testing
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### General Guidelines
|
||||
- Use test-driven development. Always write tests before implementation, and run them before and after implementation.
|
||||
- Use Docker Compose for tests. Make sure you run it in a way where you can actually see the test result.
|
||||
|
||||
### Git Workflow
|
||||
- Use the name of the branch for all pull requests
|
||||
|
||||
### Code Style
|
||||
- Prefer arrow functions, especially in classes
|
||||
See AGENTS.md
|
||||
@@ -1,4 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
WORKDIR /usr/src/app
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
42
docker-compose.prod.yml
Normal file
42
docker-compose.prod.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# self hosted server setup using Caddy
|
||||
|
||||
x-common-environment: &common-server-environment
|
||||
APNS_IS_PRODUCTION: ${APNS_IS_PRODUCTION}
|
||||
APNS_BUNDLE_ID: ${APNS_BUNDLE_ID}
|
||||
APNS_TEAM_ID: ${APNS_TEAM_ID}
|
||||
APNS_KEY_ID: ${APNS_KEY_ID}
|
||||
APNS_PRIVATE_KEY: ${APNS_PRIVATE_KEY}
|
||||
PARKING_LOGGING_INTERVAL_MS: ${PARKING_LOGGING_INTERVAL_MS}
|
||||
REDIS_URL: redis://redis:6379
|
||||
REDIS_USE_TLS: 0
|
||||
RATE_LIMITS_DISABLED: ${RATE_LIMITS_DISABLED}
|
||||
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS}
|
||||
RATE_LIMIT_DELAY_AFTER_REQUESTS: ${RATE_LIMIT_DELAY_AFTER_REQUESTS}
|
||||
RATE_LIMIT_DELAY_MULTIPLIER_MS: ${RATE_LIMIT_DELAY_MULTIPLIER_MS}
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
command: npm run start
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4000:4000"
|
||||
depends_on:
|
||||
- redis
|
||||
environment:
|
||||
<<: *common-server-environment
|
||||
NODE_ENV: production
|
||||
|
||||
redis:
|
||||
image: redis/redis-stack:7.2.0-v17
|
||||
container_name: redis-timeseries
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./redis-stack.conf:/redis-stack.conf
|
||||
command: redis-stack-server /redis-stack.conf
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -12,6 +12,7 @@ x-common-environment: &common-server-environment
|
||||
APNS_PRIVATE_KEY: ${APNS_PRIVATE_KEY}
|
||||
PARKING_LOGGING_INTERVAL_MS: ${PARKING_LOGGING_INTERVAL_MS}
|
||||
REDIS_URL: redis://redis:6379
|
||||
REDIS_USE_TLS: 0
|
||||
RATE_LIMITS_DISABLED: ${RATE_LIMITS_DISABLED}
|
||||
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS}
|
||||
RATE_LIMIT_DELAY_AFTER_REQUESTS: ${RATE_LIMIT_DELAY_AFTER_REQUESTS}
|
||||
|
||||
1124
package-lock.json
generated
1124
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -4,10 +4,10 @@
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build:dev": "npm install --include=dev && npm run generate && tsc",
|
||||
"build": "npm install --include=dev && npm run generate && tsc && npm prune --omit=dev",
|
||||
"build:dev": "npm install --include=dev && npm run generate && tsc --project tsconfig.build.json",
|
||||
"build": "npm install --include=dev && npm run generate && tsc --project tsconfig.build.json && npm prune --omit=dev",
|
||||
"start:dev": "npm run build:dev && node ./dist/index.js",
|
||||
"start": "npm run build && node ./dist/index.js",
|
||||
"start": "node ./dist/index.js",
|
||||
"generate": "graphql-codegen --config codegen.ts",
|
||||
"test": "npm run build:dev && jest --runInBand"
|
||||
},
|
||||
@@ -26,13 +26,16 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.11.2",
|
||||
"@apollo/server": "^5.0.0",
|
||||
"@as-integrations/express5": "^1.1.2",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.0.1",
|
||||
"express-slow-down": "^3.0.0",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql": "^16.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"redis": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ scalar DateTime
|
||||
type System {
|
||||
id: ID!
|
||||
name: String!
|
||||
initialRegion: Region
|
||||
routes: [Route!]
|
||||
route(id: ID): Route
|
||||
stops: [Stop!]
|
||||
@@ -16,6 +17,11 @@ type System {
|
||||
parkingSystem: ParkingSystem
|
||||
}
|
||||
|
||||
type Region {
|
||||
topLeft: Coordinates!
|
||||
bottomRight: Coordinates!
|
||||
}
|
||||
|
||||
type ParkingSystem {
|
||||
systemId: ID!
|
||||
parkingStructures: [ParkingStructure!]
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
ParkingRepositoryLoaderBuilderArguments
|
||||
} from "../loaders/parking/buildParkingRepositoryLoaderIfExists";
|
||||
import { RedisParkingRepository } from "../repositories/parking/RedisParkingRepository";
|
||||
import { ShuttleGetterRepository } from "../repositories/shuttle/ShuttleGetterRepository";
|
||||
import { InMemoryExternalSourceETARepository } from "../repositories/shuttle/eta/InMemoryExternalSourceETARepository";
|
||||
import { ETAGetterRepository } from "../repositories/shuttle/eta/ETAGetterRepository";
|
||||
import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository";
|
||||
import { BaseInMemoryETARepository } from "../repositories/shuttle/eta/BaseInMemoryETARepository";
|
||||
import { IRegion } from "./SharedEntities";
|
||||
|
||||
export interface InterchangeSystemBuilderArguments {
|
||||
name: string;
|
||||
@@ -32,6 +38,24 @@ export interface InterchangeSystemBuilderArguments {
|
||||
* ID for the parking repository ID in the codebase.
|
||||
*/
|
||||
parkingSystemId?: string;
|
||||
|
||||
/**
|
||||
* Controls whether to self-calculate ETAs or use the external
|
||||
* shuttle provider for them.
|
||||
*/
|
||||
useSelfUpdatingEtas: boolean;
|
||||
|
||||
/**
|
||||
* The size of the threshold to detect when a shuttle has arrived
|
||||
* at a stop, in latitude/longitude degrees.
|
||||
*/
|
||||
shuttleStopArrivalDegreeDelta: number;
|
||||
|
||||
/**
|
||||
* The initial map region to display when the app first loads
|
||||
* this system. Represents a center coordinate and span.
|
||||
*/
|
||||
initialRegion?: IRegion;
|
||||
}
|
||||
|
||||
export class InterchangeSystem {
|
||||
@@ -40,10 +64,12 @@ export class InterchangeSystem {
|
||||
public id: string,
|
||||
public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader,
|
||||
public shuttleRepository: ShuttleGetterSetterRepository,
|
||||
public etaRepository: ETAGetterRepository,
|
||||
public notificationScheduler: ETANotificationScheduler,
|
||||
public notificationRepository: NotificationRepository,
|
||||
public parkingTimedDataLoader: TimedApiBasedRepositoryLoader | null,
|
||||
public parkingRepository: ParkingGetterSetterRepository | null,
|
||||
public initialRegion: IRegion | null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -55,28 +81,17 @@ export class InterchangeSystem {
|
||||
static async build(
|
||||
args: InterchangeSystemBuilderArguments,
|
||||
) {
|
||||
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository();
|
||||
const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
|
||||
args.passioSystemId,
|
||||
args.id,
|
||||
shuttleRepository
|
||||
);
|
||||
const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader(
|
||||
shuttleDataLoader,
|
||||
);
|
||||
await timedShuttleDataLoader.start();
|
||||
const { shuttleRepository, timedShuttleDataLoader, etaRepository } = await InterchangeSystem.buildProductionShuttleLoaderAndRepositories(args);
|
||||
timedShuttleDataLoader.start();
|
||||
|
||||
const notificationRepository = new RedisNotificationRepository();
|
||||
await notificationRepository.connect();
|
||||
const notificationScheduler = new ETANotificationScheduler(
|
||||
const { notificationScheduler, notificationRepository } = await InterchangeSystem.buildNotificationSchedulerAndRepository(
|
||||
etaRepository,
|
||||
shuttleRepository,
|
||||
notificationRepository,
|
||||
new AppleNotificationSender(),
|
||||
args.id,
|
||||
args
|
||||
);
|
||||
notificationScheduler.startListeningForUpdates();
|
||||
|
||||
let { parkingRepository, timedParkingLoader } = await this.buildRedisParkingLoaderAndRepository(args.parkingSystemId);
|
||||
let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId);
|
||||
timedParkingLoader?.start();
|
||||
|
||||
return new InterchangeSystem(
|
||||
@@ -84,56 +99,62 @@ export class InterchangeSystem {
|
||||
args.id,
|
||||
timedShuttleDataLoader,
|
||||
shuttleRepository,
|
||||
etaRepository,
|
||||
notificationScheduler,
|
||||
notificationRepository,
|
||||
timedParkingLoader,
|
||||
parkingRepository,
|
||||
args.initialRegion ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an instance of the class where all composited
|
||||
* classes are correctly linked, meant for unit tests, and server/app
|
||||
* integration tests.
|
||||
* @param args
|
||||
*/
|
||||
static buildForTesting(
|
||||
args: InterchangeSystemBuilderArguments,
|
||||
) {
|
||||
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository();
|
||||
const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
|
||||
args.passioSystemId,
|
||||
args.id,
|
||||
shuttleRepository
|
||||
private static async buildProductionShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) {
|
||||
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(
|
||||
args.shuttleStopArrivalDegreeDelta,
|
||||
);
|
||||
// Note that this loader should not be started,
|
||||
// so the test data doesn't get overwritten
|
||||
const timedShuttleLoader = new TimedApiBasedRepositoryLoader(
|
||||
|
||||
let etaRepository: BaseInMemoryETARepository;
|
||||
let shuttleDataLoader: ApiBasedShuttleRepositoryLoader;
|
||||
if (args.useSelfUpdatingEtas) {
|
||||
etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository);
|
||||
(etaRepository as InMemorySelfUpdatingETARepository).startListeningForUpdates();
|
||||
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
|
||||
args.passioSystemId,
|
||||
args.id,
|
||||
shuttleRepository,
|
||||
);
|
||||
} else {
|
||||
etaRepository = new InMemoryExternalSourceETARepository();
|
||||
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
|
||||
args.passioSystemId,
|
||||
args.id,
|
||||
shuttleRepository,
|
||||
etaRepository as InMemoryExternalSourceETARepository,
|
||||
);
|
||||
}
|
||||
const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader(
|
||||
shuttleDataLoader,
|
||||
);
|
||||
|
||||
const notificationRepository = new InMemoryNotificationRepository();
|
||||
|
||||
return { shuttleRepository, etaRepository, timedShuttleDataLoader };
|
||||
}
|
||||
|
||||
private static async buildNotificationSchedulerAndRepository(
|
||||
etaRepository: ETAGetterRepository,
|
||||
shuttleRepository: ShuttleGetterRepository,
|
||||
args: InterchangeSystemBuilderArguments
|
||||
) {
|
||||
const notificationRepository = new RedisNotificationRepository();
|
||||
await notificationRepository.connect();
|
||||
const notificationScheduler = new ETANotificationScheduler(
|
||||
etaRepository,
|
||||
shuttleRepository,
|
||||
notificationRepository,
|
||||
new AppleNotificationSender(false),
|
||||
args.id,
|
||||
);
|
||||
notificationScheduler.startListeningForUpdates();
|
||||
|
||||
let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId);
|
||||
// Timed parking loader is not started here
|
||||
|
||||
return new InterchangeSystem(
|
||||
args.name,
|
||||
args.id,
|
||||
timedShuttleLoader,
|
||||
shuttleRepository,
|
||||
notificationScheduler,
|
||||
notificationRepository,
|
||||
timedParkingLoader,
|
||||
parkingRepository,
|
||||
new AppleNotificationSender(),
|
||||
args.id
|
||||
);
|
||||
return { notificationScheduler, notificationRepository };
|
||||
}
|
||||
|
||||
private static async buildRedisParkingLoaderAndRepository(id?: string) {
|
||||
@@ -161,7 +182,59 @@ export class InterchangeSystem {
|
||||
return { parkingRepository, timedParkingLoader };
|
||||
}
|
||||
|
||||
private static buildInMemoryParkingLoaderAndRepository(id?: string) {
|
||||
/**
|
||||
* Construct an instance of the class where all composited
|
||||
* classes are correctly linked, meant for unit tests, and server/app
|
||||
* integration tests.
|
||||
* @param args
|
||||
*/
|
||||
static buildForTesting(
|
||||
args: InterchangeSystemBuilderArguments,
|
||||
) {
|
||||
const { shuttleRepository, timedShuttleLoader, etaRepository } = InterchangeSystem.buildTestingShuttleLoaderAndRepositories(args);
|
||||
// Timed shuttle loader is not started here
|
||||
|
||||
const { notificationScheduler, notificationRepository } = InterchangeSystem.buildTestingNotificationSchedulerAndRepository(
|
||||
etaRepository,
|
||||
shuttleRepository,
|
||||
args
|
||||
);
|
||||
notificationScheduler.startListeningForUpdates();
|
||||
|
||||
let { parkingRepository, timedParkingLoader } = this.buildTestingParkingLoaderAndRepository(args.parkingSystemId);
|
||||
// Timed parking loader is not started here
|
||||
|
||||
return new InterchangeSystem(
|
||||
args.name,
|
||||
args.id,
|
||||
timedShuttleLoader,
|
||||
shuttleRepository,
|
||||
etaRepository,
|
||||
notificationScheduler,
|
||||
notificationRepository,
|
||||
timedParkingLoader,
|
||||
parkingRepository,
|
||||
args.initialRegion ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private static buildTestingNotificationSchedulerAndRepository(
|
||||
etaRepository: ETAGetterRepository,
|
||||
shuttleRepository: UnoptimizedInMemoryShuttleRepository,
|
||||
args: InterchangeSystemBuilderArguments
|
||||
) {
|
||||
const notificationRepository = new InMemoryNotificationRepository();
|
||||
const notificationScheduler = new ETANotificationScheduler(
|
||||
etaRepository,
|
||||
shuttleRepository,
|
||||
notificationRepository,
|
||||
new AppleNotificationSender(false),
|
||||
args.id
|
||||
);
|
||||
return { notificationScheduler, notificationRepository };
|
||||
}
|
||||
|
||||
private static buildTestingParkingLoaderAndRepository(id?: string) {
|
||||
if (id === undefined) {
|
||||
return { parkingRepository: null, timedParkingLoader: null };
|
||||
}
|
||||
@@ -184,4 +257,39 @@ export class InterchangeSystem {
|
||||
return { parkingRepository, timedParkingLoader };
|
||||
}
|
||||
|
||||
private static buildTestingShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) {
|
||||
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(
|
||||
args.shuttleStopArrivalDegreeDelta,
|
||||
);
|
||||
|
||||
let etaRepository: BaseInMemoryETARepository;
|
||||
let shuttleDataLoader: ApiBasedShuttleRepositoryLoader;
|
||||
if (args.useSelfUpdatingEtas) {
|
||||
etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository);
|
||||
(etaRepository as InMemorySelfUpdatingETARepository).startListeningForUpdates();
|
||||
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
|
||||
args.passioSystemId,
|
||||
args.id,
|
||||
shuttleRepository,
|
||||
);
|
||||
} else {
|
||||
etaRepository = new InMemoryExternalSourceETARepository();
|
||||
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
|
||||
args.passioSystemId,
|
||||
args.id,
|
||||
shuttleRepository,
|
||||
etaRepository as InMemoryExternalSourceETARepository,
|
||||
);
|
||||
}
|
||||
|
||||
// Note that this loader should not be started,
|
||||
// so the test data doesn't get overwritten
|
||||
const timedShuttleLoader = new TimedApiBasedRepositoryLoader(
|
||||
shuttleDataLoader
|
||||
);
|
||||
|
||||
|
||||
return { shuttleRepository, etaRepository, timedShuttleLoader };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,3 +11,8 @@ export interface ICoordinates {
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface IRegion {
|
||||
topLeft: ICoordinates;
|
||||
bottomRight: ICoordinates;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,3 +37,19 @@ export interface IOrderedStop extends IEntityWithTimestamp {
|
||||
systemId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a shuttle has arrived at a stop based on coordinate proximity.
|
||||
* Uses a threshold of 0.001 degrees (~111 meters at the equator).
|
||||
*/
|
||||
export function shuttleHasArrivedAtStop(
|
||||
shuttle: IShuttle,
|
||||
stop: IStop,
|
||||
delta = 0.001
|
||||
) {
|
||||
const isWithinLatitudeRange = shuttle.coordinates.latitude > stop.coordinates.latitude - delta
|
||||
&& shuttle.coordinates.latitude < stop.coordinates.latitude + delta;
|
||||
const isWithinLongitudeRange = shuttle.coordinates.longitude > stop.coordinates.longitude - delta
|
||||
&& shuttle.coordinates.longitude < stop.coordinates.longitude + delta
|
||||
return isWithinLatitudeRange && isWithinLongitudeRange;
|
||||
}
|
||||
|
||||
|
||||
65
src/entities/__tests__/ShuttleRepositoryEntities.test.ts
Normal file
65
src/entities/__tests__/ShuttleRepositoryEntities.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { shuttleHasArrivedAtStop, IShuttle, IStop } from "../ShuttleRepositoryEntities";
|
||||
|
||||
describe("shuttleHasArrivedAtStop", () => {
|
||||
const baseStop: IStop = {
|
||||
id: "stop1",
|
||||
name: "Test Stop",
|
||||
systemId: "263",
|
||||
coordinates: {
|
||||
latitude: 33.7963,
|
||||
longitude: -117.8540,
|
||||
},
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
|
||||
const createShuttle = (latitude: number, longitude: number): IShuttle => ({
|
||||
id: "shuttle1",
|
||||
name: "Test Shuttle",
|
||||
routeId: "route1",
|
||||
systemId: "263",
|
||||
coordinates: { latitude, longitude },
|
||||
orientationInDegrees: 0,
|
||||
updatedTime: new Date(),
|
||||
});
|
||||
|
||||
it("returns false when shuttle is above latitude range", () => {
|
||||
const shuttle = createShuttle(
|
||||
baseStop.coordinates.latitude + 0.0011,
|
||||
baseStop.coordinates.longitude
|
||||
);
|
||||
expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when shuttle is below latitude range", () => {
|
||||
const shuttle = createShuttle(
|
||||
baseStop.coordinates.latitude - 0.0011,
|
||||
baseStop.coordinates.longitude
|
||||
);
|
||||
expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when shuttle is to left of longitude range", () => {
|
||||
const shuttle = createShuttle(
|
||||
baseStop.coordinates.latitude,
|
||||
baseStop.coordinates.longitude - 0.0011
|
||||
);
|
||||
expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when shuttle is to right of longitude range", () => {
|
||||
const shuttle = createShuttle(
|
||||
baseStop.coordinates.latitude,
|
||||
baseStop.coordinates.longitude + 0.0011
|
||||
);
|
||||
expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when shuttle is in the range", () => {
|
||||
const shuttle = createShuttle(
|
||||
baseStop.coordinates.latitude + 0.0005,
|
||||
baseStop.coordinates.longitude - 0.0005
|
||||
);
|
||||
expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,3 +22,5 @@ export const RATE_LIMIT_DELAY_MULTIPLIER_MS = process.env.RATE_LIMIT_DELAY_MULTI
|
||||
: 1000;
|
||||
|
||||
export const REDIS_RECONNECT_INTERVAL = 30000;
|
||||
|
||||
export const SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES = 5;
|
||||
|
||||
15
src/helpers/createRedisClientForRepository.ts
Normal file
15
src/helpers/createRedisClientForRepository.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import { REDIS_RECONNECT_INTERVAL } from "../environment";
|
||||
|
||||
export default function createRedisClientForRepository() {
|
||||
const client = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
socket: {
|
||||
tls: process.env.REDIS_USE_TLS === "1",
|
||||
rejectUnauthorized: false,
|
||||
reconnectStrategy: REDIS_RECONNECT_INTERVAL,
|
||||
} as object,
|
||||
});
|
||||
return client as RedisClientType;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ const supportedSystems: InterchangeSystemBuilderArguments[] = [
|
||||
passioSystemId: "263",
|
||||
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
|
||||
name: "Chapman University",
|
||||
useSelfUpdatingEtas: true,
|
||||
shuttleStopArrivalDegreeDelta: 0.001,
|
||||
initialRegion: {
|
||||
topLeft: { latitude: 33.85733, longitude: -117.89553 },
|
||||
bottomRight: { latitude: 33.73970, longitude: -117.81878 },
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export interface RepositoryLoader {
|
||||
fetchAndUpdateAll(): Promise<void>;
|
||||
updateAll(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class TimedApiBasedRepositoryLoader {
|
||||
if (!this.shouldBeRunning) return;
|
||||
|
||||
try {
|
||||
await this.loader.fetchAndUpdateAll();
|
||||
await this.loader.updateAll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { TimedApiBasedRepositoryLoader } from "../TimedApiBasedRepositoryLoader";
|
||||
import { resetGlobalFetchMockJson } from "../../../testHelpers/fetchMockHelpers";
|
||||
import { UnoptimizedInMemoryShuttleRepository } from "../../repositories/shuttle/UnoptimizedInMemoryShuttleRepository";
|
||||
@@ -23,7 +23,7 @@ describe("TimedApiBasedRepositoryLoader", () => {
|
||||
);
|
||||
|
||||
spies = {
|
||||
fetchAndUpdateAll: jest.spyOn(mockLoader, 'fetchAndUpdateAll'),
|
||||
updateAll: jest.spyOn(mockLoader, 'updateAll'),
|
||||
};
|
||||
|
||||
Object.values(spies).forEach((spy: any) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export class ChapmanApiBasedParkingRepositoryLoader implements ParkingRepository
|
||||
this.fetchAndUpdateParkingStructures = this.fetchAndUpdateParkingStructures.bind(this);
|
||||
}
|
||||
|
||||
async fetchAndUpdateAll() {
|
||||
async updateAll() {
|
||||
await this.fetchAndUpdateParkingStructures();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("ChapmanApiBasedParkingRepositoryLoader", () => {
|
||||
spy.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
await loader.fetchAndUpdateAll();
|
||||
await loader.updateAll();
|
||||
|
||||
Object.values(spies).forEach((spy: any) => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository";
|
||||
import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader";
|
||||
import { IEntityWithId } from "../../entities/SharedEntities";
|
||||
import { ICoordinates, IEntityWithId } from "../../entities/SharedEntities";
|
||||
import { ApiResponseError } from "../ApiResponseError";
|
||||
import { SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES } from "../../environment";
|
||||
import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository";
|
||||
|
||||
/**
|
||||
* Class which can load data into a repository from the
|
||||
@@ -10,12 +12,14 @@ import { ApiResponseError } from "../ApiResponseError";
|
||||
* which inherit from `IEntityWithId`.
|
||||
*/
|
||||
export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader {
|
||||
baseUrl = "https://passiogo.com/mapGetData.php";
|
||||
readonly baseUrl = "https://passiogo.com/mapGetData.php";
|
||||
|
||||
constructor(
|
||||
public passioSystemId: string,
|
||||
public systemIdForConstructedData: string,
|
||||
public repository: ShuttleGetterSetterRepository,
|
||||
public shuttleRepository: ShuttleGetterSetterRepository,
|
||||
public etaRepository?: ExternalSourceETARepository,
|
||||
readonly shuttleToRouteCoordinateMaximumDistanceMiles = SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -28,23 +32,48 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async fetchAndUpdateAll() {
|
||||
await this.fetchAndUpdateRouteDataForSystem();
|
||||
await this.fetchAndUpdateStopAndPolylineDataForRoutesInSystem();
|
||||
await this.fetchAndUpdateShuttleDataForSystem();
|
||||
public async updateAll() {
|
||||
await this.updateRouteDataForSystem();
|
||||
await this.updateStopAndPolylineDataForRoutesInSystem();
|
||||
await this.updateShuttleDataForSystemBasedOnProximityToRoutes();
|
||||
|
||||
// Because ETA method doesn't support pruning yet,
|
||||
// add a call to the clear method here
|
||||
await this.repository.clearEtaData();
|
||||
await this.fetchAndUpdateEtaDataForExistingStopsForSystem();
|
||||
if (this.etaRepository) {
|
||||
await this.updateEtaDataForExistingStopsForSystem();
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchAndUpdateRouteDataForSystem() {
|
||||
const systemId = this.passioSystemId;
|
||||
public async updateRouteDataForSystem() {
|
||||
try {
|
||||
const json = await this.fetchRouteDataJson();
|
||||
const routes = this.constructRoutesFromJson(json);
|
||||
if (routes !== null) {
|
||||
await this.updateRouteDataInRepository(routes);
|
||||
} else {
|
||||
console.warn(`Route update failed for the following JSON: ${JSON.stringify(json)}`);
|
||||
}
|
||||
} catch(e: any) {
|
||||
throw new ApiResponseError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRouteDataInRepository(routes: IRoute[]) {
|
||||
const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => {
|
||||
return await this.repository.getRoutes();
|
||||
return await this.shuttleRepository.getRoutes();
|
||||
});
|
||||
|
||||
await Promise.all(routes.map(async (route) => {
|
||||
await this.shuttleRepository.addOrUpdateRoute(route);
|
||||
routeIdsToPrune.delete(route.id);
|
||||
}));
|
||||
|
||||
await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => {
|
||||
await this.shuttleRepository.removeRouteIfExists(routeId);
|
||||
}));
|
||||
}
|
||||
|
||||
private async fetchRouteDataJson() {
|
||||
const systemId = this.passioSystemId;
|
||||
|
||||
const params = {
|
||||
getRoutes: "2",
|
||||
};
|
||||
@@ -52,53 +81,63 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
const formDataJsonObject = {
|
||||
"systemSelected0": systemId,
|
||||
"amount": "1",
|
||||
}
|
||||
};
|
||||
const formData = new FormData();
|
||||
formData.set("json", JSON.stringify(formDataJsonObject));
|
||||
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
private constructRoutesFromJson(json: any): IRoute[] | null {
|
||||
if (typeof json.all === "object") {
|
||||
return json.all.map((jsonRoute: any) => {
|
||||
const constructedRoute: IRoute = {
|
||||
name: jsonRoute.name,
|
||||
color: jsonRoute.color,
|
||||
id: jsonRoute.myid,
|
||||
polylineCoordinates: [],
|
||||
systemId: this.systemIdForConstructedData,
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
return constructedRoute;
|
||||
});
|
||||
const json = await response.json();
|
||||
}
|
||||
|
||||
if (typeof json.all === "object") {
|
||||
await Promise.all(json.all.map(async (jsonRoute: any) => {
|
||||
const constructedRoute: IRoute = {
|
||||
name: jsonRoute.name,
|
||||
color: jsonRoute.color,
|
||||
id: jsonRoute.myid,
|
||||
polylineCoordinates: [],
|
||||
systemId: this.systemIdForConstructedData,
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.repository.addOrUpdateRoute(constructedRoute);
|
||||
|
||||
routeIdsToPrune.delete(constructedRoute.id);
|
||||
}))
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => {
|
||||
await this.repository.removeRouteIfExists(routeId);
|
||||
}));
|
||||
public async updateStopAndPolylineDataForRoutesInSystem() {
|
||||
try {
|
||||
const json = await this.fetchStopAndPolylineDataJson();
|
||||
await this.updateStopAndPolylineDataInRepository(json);
|
||||
} catch(e: any) {
|
||||
throw new ApiResponseError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchAndUpdateStopAndPolylineDataForRoutesInSystem() {
|
||||
const passioSystemId = this.passioSystemId;
|
||||
|
||||
// Fetch from the API
|
||||
// Pass JSON output into two different methods to update repository
|
||||
private async updateStopAndPolylineDataInRepository(json: any) {
|
||||
const stopIdsToPrune = await this.constructExistingEntityIdSet(async () => {
|
||||
return await this.repository.getStops();
|
||||
return await this.shuttleRepository.getStops();
|
||||
});
|
||||
|
||||
await this.updateStopDataForSystemAndApiResponse(json, stopIdsToPrune);
|
||||
await this.updateOrderedStopDataForExistingStops(json);
|
||||
await this.updatePolylineDataForExistingRoutesAndApiResponse(json);
|
||||
|
||||
await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => {
|
||||
await this.shuttleRepository.removeStopIfExists(stopId);
|
||||
}));
|
||||
}
|
||||
|
||||
private async fetchStopAndPolylineDataJson() {
|
||||
const passioSystemId = this.passioSystemId;
|
||||
|
||||
const params = {
|
||||
getStops: "2",
|
||||
};
|
||||
@@ -112,31 +151,46 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async updateShuttleDataForSystemBasedOnProximityToRoutes() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const json = await response.json();
|
||||
|
||||
await this.updateStopDataForSystemAndApiResponse(json, stopIdsToPrune);
|
||||
await this.updateOrderedStopDataForExistingStops(json);
|
||||
await this.updatePolylineDataForExistingRoutesAndApiResponse(json);
|
||||
|
||||
await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => {
|
||||
await this.repository.removeStopIfExists(stopId);
|
||||
}));
|
||||
const json = await this.fetchShuttleDataJson();
|
||||
let shuttles = this.constructInServiceShuttlesFromJson(json);
|
||||
if (shuttles !== null) {
|
||||
shuttles = await this.filterShuttlesByDistanceFromCorrespondingRoute(shuttles);
|
||||
await this.updateShuttleDataInRepository(shuttles);
|
||||
} else {
|
||||
console.warn(`Shuttle update failed for the following JSON: ${JSON.stringify(json)}`)
|
||||
}
|
||||
} catch(e: any) {
|
||||
throw new ApiResponseError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchAndUpdateShuttleDataForSystem() {
|
||||
const systemId = this.passioSystemId;
|
||||
private async updateShuttleDataInRepository(shuttles: IShuttle[]) {
|
||||
const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => {
|
||||
return await this.repository.getShuttles();
|
||||
return await this.shuttleRepository.getShuttles();
|
||||
});
|
||||
|
||||
await Promise.all(shuttles.map(async (shuttle) => {
|
||||
await this.shuttleRepository.addOrUpdateShuttle(shuttle);
|
||||
shuttleIdsToPrune.delete(shuttle.id);
|
||||
}));
|
||||
|
||||
await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => {
|
||||
await this.shuttleRepository.removeShuttleIfExists(shuttleId);
|
||||
}));
|
||||
}
|
||||
|
||||
private async fetchShuttleDataJson() {
|
||||
const systemId = this.passioSystemId;
|
||||
|
||||
const params = {
|
||||
getBuses: "2"
|
||||
};
|
||||
@@ -151,55 +205,69 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
private constructInServiceShuttlesFromJson(json: any): IShuttle[] | null {
|
||||
if (json.buses && json.buses["-1"] === undefined) {
|
||||
const jsonBuses = Object.values(json.buses).map((busesArr: any) => {
|
||||
return busesArr[0];
|
||||
});
|
||||
const json = await response.json();
|
||||
jsonBuses.filter((bus) => bus.outOfService != 0)
|
||||
|
||||
if (json.buses && json.buses["-1"] === undefined) {
|
||||
const jsonBuses = Object.values(json.buses).map((busesArr: any) => {
|
||||
return busesArr[0];
|
||||
});
|
||||
return jsonBuses.map((jsonBus: any) => {
|
||||
const constructedShuttle: IShuttle = {
|
||||
name: jsonBus.bus,
|
||||
coordinates: {
|
||||
latitude: parseFloat(jsonBus.latitude),
|
||||
longitude: parseFloat(jsonBus.longitude),
|
||||
},
|
||||
routeId: jsonBus.routeId,
|
||||
systemId: this.systemIdForConstructedData,
|
||||
id: `${jsonBus.busId}`,
|
||||
orientationInDegrees: parseFloat(jsonBus.calculatedCourse),
|
||||
updatedTime: new Date(),
|
||||
}
|
||||
return constructedShuttle;
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(jsonBuses.map(async (jsonBus: any) => {
|
||||
const constructedShuttle: IShuttle = {
|
||||
name: jsonBus.bus,
|
||||
coordinates: {
|
||||
latitude: parseFloat(jsonBus.latitude),
|
||||
longitude: parseFloat(jsonBus.longitude),
|
||||
},
|
||||
routeId: jsonBus.routeId,
|
||||
systemId: this.systemIdForConstructedData,
|
||||
id: `${jsonBus.busId}`,
|
||||
orientationInDegrees: parseFloat(jsonBus.calculatedCourse),
|
||||
updatedTime: new Date(),
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.repository.addOrUpdateShuttle(constructedShuttle);
|
||||
public async updateEtaDataForExistingStopsForSystem() {
|
||||
const stops = await this.shuttleRepository.getStops();
|
||||
await Promise.all(stops.map(async (stop) => {
|
||||
let stopId = stop.id;
|
||||
await this.updateEtaDataForStopId(stopId);
|
||||
}));
|
||||
}
|
||||
|
||||
shuttleIdsToPrune.delete(constructedShuttle.id);
|
||||
}));
|
||||
public async updateEtaDataForStopId(stopId: string) {
|
||||
try {
|
||||
const json = await this.fetchEtaDataJson(stopId);
|
||||
const etas = this.constructEtasFromJson(json, stopId);
|
||||
if (etas !== null) {
|
||||
await this.updateEtaDataInRepository(etas);
|
||||
} else {
|
||||
console.warn(`ETA update failed for stop ${stopId} with the following JSON: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => {
|
||||
await this.repository.removeShuttleIfExists(shuttleId);
|
||||
}));
|
||||
} catch(e: any) {
|
||||
throw new ApiResponseError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchAndUpdateEtaDataForExistingStopsForSystem() {
|
||||
const stops = await this.repository.getStops();
|
||||
await Promise.all(stops.map(async (stop) => {
|
||||
let stopId = stop.id;
|
||||
await this.fetchAndUpdateEtaDataForStopId(stopId);
|
||||
private async updateEtaDataInRepository(etas: IEta[]) {
|
||||
await Promise.all(etas.map(async (eta) => {
|
||||
await this.etaRepository?.addOrUpdateEtaFromExternalSource(eta);
|
||||
}));
|
||||
}
|
||||
|
||||
public async fetchAndUpdateEtaDataForStopId(stopId: string) {
|
||||
private async fetchEtaDataJson(stopId: string) {
|
||||
const params = {
|
||||
eta: "3",
|
||||
stopIds: stopId,
|
||||
@@ -207,32 +275,28 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "GET",
|
||||
const response = await fetch(`${this.baseUrl}?${query}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
private constructEtasFromJson(json: any, stopId: string): IEta[] | null {
|
||||
if (json.ETAs && json.ETAs[stopId]) {
|
||||
return json.ETAs[stopId].map((jsonEta: any) => {
|
||||
const shuttleId: string = jsonEta.busId;
|
||||
const eta: IEta = {
|
||||
secondsRemaining: jsonEta.secondsSpent,
|
||||
shuttleId: `${shuttleId}`,
|
||||
stopId: stopId,
|
||||
updatedTime: new Date(),
|
||||
systemId: this.systemIdForConstructedData,
|
||||
};
|
||||
return eta;
|
||||
});
|
||||
const json = await response.json();
|
||||
|
||||
if (json.ETAs && json.ETAs[stopId]) {
|
||||
// Continue with the parsing
|
||||
json.ETAs[stopId].forEach((jsonEta: any) => {
|
||||
// Update cache
|
||||
const shuttleId: string = jsonEta.busId;
|
||||
|
||||
const eta: IEta = {
|
||||
secondsRemaining: jsonEta.secondsSpent,
|
||||
shuttleId: `${shuttleId}`,
|
||||
stopId: stopId,
|
||||
updatedTime: new Date(),
|
||||
systemId: this.systemIdForConstructedData,
|
||||
};
|
||||
|
||||
this.repository.addOrUpdateEta(eta);
|
||||
});
|
||||
}
|
||||
} catch(e: any) {
|
||||
throw new ApiResponseError(e.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async updateStopDataForSystemAndApiResponse(
|
||||
@@ -254,7 +318,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
|
||||
await this.repository.addOrUpdateStop(constructedStop);
|
||||
await this.shuttleRepository.addOrUpdateStop(constructedStop);
|
||||
|
||||
setOfIdsToPrune.delete(constructedStop.id);
|
||||
}));
|
||||
@@ -276,7 +340,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
const orderedStopDataArray = jsonOrderedStopData[index];
|
||||
|
||||
const stopId = orderedStopDataArray[1];
|
||||
let constructedOrderedStop = await this.repository.getOrderedStopByRouteAndStopId(routeId, stopId)
|
||||
let constructedOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(routeId, stopId)
|
||||
if (constructedOrderedStop === null) {
|
||||
constructedOrderedStop = {
|
||||
routeId,
|
||||
@@ -306,7 +370,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
};
|
||||
}
|
||||
|
||||
await this.repository.addOrUpdateOrderedStop(constructedOrderedStop);
|
||||
await this.shuttleRepository.addOrUpdateOrderedStop(constructedOrderedStop);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -317,7 +381,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
await Promise.all(Object.keys(json.routePoints).map(async (routeId) => {
|
||||
const routePoints = json.routePoints[routeId][0];
|
||||
|
||||
const existingRoute = await this.repository.getRouteById(routeId);
|
||||
const existingRoute = await this.shuttleRepository.getRouteById(routeId);
|
||||
if (!existingRoute) return;
|
||||
|
||||
existingRoute.polylineCoordinates = routePoints.map((point: any) => {
|
||||
@@ -327,8 +391,51 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader
|
||||
};
|
||||
});
|
||||
|
||||
await this.repository.addOrUpdateRoute(existingRoute);
|
||||
await this.shuttleRepository.addOrUpdateRoute(existingRoute);
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private filterShuttlesByDistanceFromCorrespondingRoute = async (shuttles: IShuttle[]) => {
|
||||
let filteredShuttles: IShuttle[] = [];
|
||||
|
||||
await Promise.all(shuttles.map(async (shuttle) => {
|
||||
const route = await this.shuttleRepository.getRouteById(shuttle.routeId);
|
||||
if (route != null) {
|
||||
let closestDistanceMiles = Number.MAX_VALUE;
|
||||
|
||||
route.polylineCoordinates.forEach((coordinate) => {
|
||||
const calculatedDistance = ApiBasedShuttleRepositoryLoader.convertDistanceBetweenCoordinatesToMiles(coordinate, shuttle.coordinates)
|
||||
if (closestDistanceMiles > calculatedDistance) {
|
||||
closestDistanceMiles = calculatedDistance;
|
||||
}
|
||||
});
|
||||
|
||||
if (closestDistanceMiles <= this.shuttleToRouteCoordinateMaximumDistanceMiles) {
|
||||
filteredShuttles.push(shuttle);
|
||||
}
|
||||
} else {
|
||||
console.warn(`No corresponding route found for ID ${shuttle.routeId} of shuttle ${shuttle.id}`)
|
||||
}
|
||||
}));
|
||||
|
||||
return filteredShuttles;
|
||||
};
|
||||
|
||||
public static convertDistanceBetweenCoordinatesToMiles = (fromCoordinate: ICoordinates, toCoordinate: ICoordinates): number => {
|
||||
const earthRadiusMiles = 3959;
|
||||
|
||||
const lat1Rad = fromCoordinate.latitude * Math.PI / 180;
|
||||
const lat2Rad = toCoordinate.latitude * Math.PI / 180;
|
||||
const deltaLatRad = (toCoordinate.latitude - fromCoordinate.latitude) * Math.PI / 180;
|
||||
const deltaLonRad = (toCoordinate.longitude - fromCoordinate.longitude) * Math.PI / 180;
|
||||
|
||||
const a = Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
|
||||
Math.cos(lat1Rad) * Math.cos(lat2Rad) *
|
||||
Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return earthRadiusMiles * c;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RepositoryLoader } from "../RepositoryLoader";
|
||||
|
||||
export interface ShuttleRepositoryLoader extends RepositoryLoader {
|
||||
fetchAndUpdateRouteDataForSystem(): Promise<void>;
|
||||
fetchAndUpdateStopAndPolylineDataForRoutesInSystem(): Promise<void>;
|
||||
fetchAndUpdateShuttleDataForSystem(): Promise<void>;
|
||||
fetchAndUpdateEtaDataForExistingStopsForSystem(): Promise<void>;
|
||||
fetchAndUpdateEtaDataForStopId(stopId: string): Promise<void>;
|
||||
updateRouteDataForSystem(): Promise<void>;
|
||||
updateStopAndPolylineDataForRoutesInSystem(): Promise<void>;
|
||||
updateShuttleDataForSystemBasedOnProximityToRoutes(): Promise<void>;
|
||||
updateEtaDataForExistingStopsForSystem(): Promise<void>;
|
||||
updateEtaDataForStopId(stopId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../
|
||||
import {
|
||||
fetchShuttleDataSuccessfulResponse
|
||||
} from "../../../../testHelpers/jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse";
|
||||
import { fetchEtaDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchEtaData/fetchEtaDataSuccessfulResponse";
|
||||
import { InMemoryExternalSourceETARepository } from "../../../repositories/shuttle/eta/InMemoryExternalSourceETARepository";
|
||||
import {
|
||||
resetGlobalFetchMockJson,
|
||||
updateGlobalFetchMockJson,
|
||||
updateGlobalFetchMockJsonToThrowSyntaxError
|
||||
} from "../../../../testHelpers/fetchMockHelpers";
|
||||
import { assertAsyncCallbackThrowsApiResponseError } from "../../../../testHelpers/assertAsyncCallbackThrowsApiResponseError";
|
||||
import { IRoute } from "../../../entities/ShuttleRepositoryEntities";
|
||||
|
||||
describe("ApiBasedShuttleRepositoryLoader", () => {
|
||||
let loader: ApiBasedShuttleRepositoryLoader;
|
||||
@@ -31,20 +32,19 @@ describe("ApiBasedShuttleRepositoryLoader", () => {
|
||||
|
||||
const systemId = "1";
|
||||
|
||||
describe("fetchAndUpdateAll", () => {
|
||||
describe("updateAll", () => {
|
||||
it("calls all the correct methods", async () => {
|
||||
const spies = {
|
||||
fetchAndUpdateRouteDataForSystem: jest.spyOn(loader, "fetchAndUpdateRouteDataForSystem"),
|
||||
fetchAndUpdateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, "fetchAndUpdateStopAndPolylineDataForRoutesInSystem"),
|
||||
fetchAndUpdateShuttleDataForSystem: jest.spyOn(loader, "fetchAndUpdateShuttleDataForSystem"),
|
||||
fetchAndUpdateEtaDataForExistingStopsForSystem: jest.spyOn(loader, "fetchAndUpdateEtaDataForExistingStopsForSystem"),
|
||||
updateRouteDataForSystem: jest.spyOn(loader, "updateRouteDataForSystem"),
|
||||
updateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, "updateStopAndPolylineDataForRoutesInSystem"),
|
||||
updateShuttleDataForSystem: jest.spyOn(loader, "updateShuttleDataForSystemBasedOnProximityToRoutes"),
|
||||
};
|
||||
|
||||
Object.values(spies).forEach((spy: any) => {
|
||||
spy.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
await loader.fetchAndUpdateAll();
|
||||
await loader.updateAll();
|
||||
|
||||
Object.values(spies).forEach((spy: any) => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -52,23 +52,23 @@ describe("ApiBasedShuttleRepositoryLoader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAndUpdateRouteDataForSystem", () => {
|
||||
describe("updateRouteDataForSystem", () => {
|
||||
it("updates route data in repository if response received", async () => {
|
||||
// Arrange
|
||||
// Test pruning
|
||||
const routesToPrune = generateMockRoutes();
|
||||
await Promise.all(routesToPrune.map(async (route) => {
|
||||
route.systemId = systemId;
|
||||
await loader.repository.addOrUpdateRoute(route);
|
||||
await loader.shuttleRepository.addOrUpdateRoute(route);
|
||||
}));
|
||||
|
||||
updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse);
|
||||
|
||||
// Act
|
||||
await loader.fetchAndUpdateRouteDataForSystem();
|
||||
await loader.updateRouteDataForSystem();
|
||||
|
||||
// Assert
|
||||
const routes = await loader.repository.getRoutes();
|
||||
const routes = await loader.shuttleRepository.getRoutes();
|
||||
|
||||
expect(routes.length).toEqual(fetchRouteDataSuccessfulResponse.all.length)
|
||||
});
|
||||
@@ -80,36 +80,36 @@ describe("ApiBasedShuttleRepositoryLoader", () => {
|
||||
updateGlobalFetchMockJsonToThrowSyntaxError();
|
||||
|
||||
await assertAsyncCallbackThrowsApiResponseError(async () => {
|
||||
await loader.fetchAndUpdateRouteDataForSystem();
|
||||
await loader.updateRouteDataForSystem();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId", () => {
|
||||
describe("updateStopAndPolylineDataForRoutesInSystem", () => {
|
||||
it("updates stop and polyline data if response received", async () => {
|
||||
// Arrange
|
||||
// Test pruning of stops only
|
||||
const stopsToPrune = generateMockStops();
|
||||
await Promise.all(stopsToPrune.map(async (stop) => {
|
||||
stop.systemId = systemId;
|
||||
await loader.repository.addOrUpdateStop(stop);
|
||||
await loader.shuttleRepository.addOrUpdateStop(stop);
|
||||
}));
|
||||
|
||||
updateGlobalFetchMockJson(fetchStopAndPolylineDataSuccessfulResponse);
|
||||
|
||||
const stopsArray = Object.values(fetchStopAndPolylineDataSuccessfulResponse.stops);
|
||||
|
||||
await loader.fetchAndUpdateStopAndPolylineDataForRoutesInSystem();
|
||||
await loader.updateStopAndPolylineDataForRoutesInSystem();
|
||||
|
||||
const stops = await loader.repository.getStops();
|
||||
const stops = await loader.shuttleRepository.getStops();
|
||||
expect(stops.length).toEqual(stopsArray.length);
|
||||
|
||||
await Promise.all(stops.map(async (stop) => {
|
||||
const orderedStops = await loader.repository.getOrderedStopsByStopId(stop.id)
|
||||
const orderedStops = await loader.shuttleRepository.getOrderedStopsByStopId(stop.id)
|
||||
expect(orderedStops.length).toBeGreaterThan(0);
|
||||
}));
|
||||
|
||||
const routes = await loader.repository.getRoutes();
|
||||
const routes = await loader.shuttleRepository.getRoutes();
|
||||
routes.forEach((route) => {
|
||||
expect(route.polylineCoordinates.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -119,75 +119,143 @@ describe("ApiBasedShuttleRepositoryLoader", () => {
|
||||
updateGlobalFetchMockJsonToThrowSyntaxError();
|
||||
|
||||
await assertAsyncCallbackThrowsApiResponseError(async () => {
|
||||
await loader.fetchAndUpdateStopAndPolylineDataForRoutesInSystem();
|
||||
await loader.updateStopAndPolylineDataForRoutesInSystem();
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
describe("fetchAndUpdateShuttleDataForSystem", () => {
|
||||
it("updates shuttle data in repository if response received", async () => {
|
||||
const shuttlesToPrune = generateMockShuttles();
|
||||
await Promise.all(shuttlesToPrune.map(async (shuttle) => {
|
||||
shuttle.systemId = systemId;
|
||||
await loader.repository.addOrUpdateShuttle(shuttle);
|
||||
}))
|
||||
describe("updateShuttleDataForSystemBasedOnProximityToRoutes", () => {
|
||||
function generateMockRoutesWithPolylineCoordinates() {
|
||||
const routes = generateMockRoutes();
|
||||
routes[0].polylineCoordinates = [
|
||||
{latitude: 33.78792, longitude: -117.86187},
|
||||
{latitude: 33.78792, longitude: -117.86200},
|
||||
{latitude: 33.78792, longitude: -117.86245}
|
||||
];
|
||||
return routes;
|
||||
}
|
||||
|
||||
updateGlobalFetchMockJson(fetchShuttleDataSuccessfulResponse);
|
||||
const busesInResponse = Object.values(fetchShuttleDataSuccessfulResponse.buses);
|
||||
function getMockJsonResponseMatchingRouteAndCoordinates(route: IRoute, longitude: string, latitude: string) {
|
||||
const modifiedSuccessfulResponse = {
|
||||
...fetchShuttleDataSuccessfulResponse,
|
||||
};
|
||||
|
||||
await loader.fetchAndUpdateShuttleDataForSystem();
|
||||
Object.keys(modifiedSuccessfulResponse.buses).forEach((busId) => {
|
||||
const bus = (modifiedSuccessfulResponse.buses as any)[busId][0];
|
||||
bus.latitude = latitude;
|
||||
bus.longitude = longitude;
|
||||
bus.routeId = route.id;
|
||||
});
|
||||
return modifiedSuccessfulResponse;
|
||||
}
|
||||
|
||||
const shuttles = await loader.repository.getShuttles();
|
||||
async function addMockRoutes(routes: IRoute[]) {
|
||||
await Promise.all(routes.map(async (route) => {
|
||||
await loader.shuttleRepository.addOrUpdateRoute(route);
|
||||
}));
|
||||
}
|
||||
|
||||
it("updates shuttle data in repository from API if shuttles close enough to route", async () => {
|
||||
const distanceMiles = 1;
|
||||
loader = new ApiBasedShuttleRepositoryLoader(
|
||||
"263",
|
||||
"1",
|
||||
new UnoptimizedInMemoryShuttleRepository(),
|
||||
new InMemoryExternalSourceETARepository(),
|
||||
distanceMiles,
|
||||
);
|
||||
|
||||
const routes = generateMockRoutesWithPolylineCoordinates();
|
||||
await addMockRoutes(routes);
|
||||
const modifiedSuccessfulResponse = getMockJsonResponseMatchingRouteAndCoordinates(
|
||||
routes[0],
|
||||
"-117.86187",
|
||||
"33.78792"
|
||||
);
|
||||
updateGlobalFetchMockJson(modifiedSuccessfulResponse);
|
||||
const busesInResponse = Object.values(modifiedSuccessfulResponse.buses);
|
||||
|
||||
await loader.updateShuttleDataForSystemBasedOnProximityToRoutes();
|
||||
|
||||
const shuttles = await loader.shuttleRepository.getShuttles();
|
||||
expect(shuttles.length).toEqual(busesInResponse.length);
|
||||
});
|
||||
|
||||
it("throws the correct error if the API response contains no data", async () => {
|
||||
updateGlobalFetchMockJsonToThrowSyntaxError();
|
||||
it("does not update shuttle data in repository from API if shuttles are not close enough to route", async () => {
|
||||
const distanceMiles = 1;
|
||||
loader = new ApiBasedShuttleRepositoryLoader(
|
||||
"263",
|
||||
"1",
|
||||
new UnoptimizedInMemoryShuttleRepository(),
|
||||
new InMemoryExternalSourceETARepository(),
|
||||
distanceMiles,
|
||||
);
|
||||
|
||||
await assertAsyncCallbackThrowsApiResponseError(async () => {
|
||||
await loader.fetchAndUpdateShuttleDataForSystem();
|
||||
});
|
||||
const routes = generateMockRoutesWithPolylineCoordinates();
|
||||
await addMockRoutes(routes);
|
||||
|
||||
const modifiedSuccessfulResponse = getMockJsonResponseMatchingRouteAndCoordinates(
|
||||
routes[0],
|
||||
"-116.86187",
|
||||
"32.78792"
|
||||
);
|
||||
updateGlobalFetchMockJson(modifiedSuccessfulResponse);
|
||||
|
||||
await loader.updateShuttleDataForSystemBasedOnProximityToRoutes();
|
||||
|
||||
const shuttles = await loader.shuttleRepository.getShuttles();
|
||||
expect(shuttles.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAndUpdateEtaDataForExistingStopsForSystem", () => {
|
||||
it("calls fetchAndUpdateEtaDataForStopId for every stop in repository", async () => {
|
||||
const spy = jest.spyOn(loader, "fetchAndUpdateEtaDataForStopId");
|
||||
it("prunes shuttles correctly", async () => {
|
||||
const distanceMiles = 1;
|
||||
loader = new ApiBasedShuttleRepositoryLoader(
|
||||
"263",
|
||||
"1",
|
||||
new UnoptimizedInMemoryShuttleRepository(),
|
||||
new InMemoryExternalSourceETARepository(),
|
||||
distanceMiles,
|
||||
);
|
||||
|
||||
const stops = generateMockStops();
|
||||
stops.forEach((stop) => {
|
||||
stop.systemId = "1";
|
||||
});
|
||||
|
||||
await Promise.all(stops.map(async (stop) => {
|
||||
await loader.repository.addOrUpdateStop(stop);
|
||||
// Add mock shuttles to repository (these should be pruned)
|
||||
const shuttlesToPrune = generateMockShuttles();
|
||||
await Promise.all(shuttlesToPrune.map(async (shuttle) => {
|
||||
shuttle.systemId = systemId;
|
||||
await loader.shuttleRepository.addOrUpdateShuttle(shuttle);
|
||||
}));
|
||||
|
||||
await loader.fetchAndUpdateEtaDataForExistingStopsForSystem();
|
||||
const routes = generateMockRoutesWithPolylineCoordinates();
|
||||
await addMockRoutes(routes);
|
||||
|
||||
expect(spy.mock.calls.length).toEqual(stops.length);
|
||||
});
|
||||
});
|
||||
const modifiedSuccessfulResponse = getMockJsonResponseMatchingRouteAndCoordinates(
|
||||
routes[0],
|
||||
"-117.86187",
|
||||
"33.78792"
|
||||
);
|
||||
|
||||
describe("fetchAndUpdateEtaDataForStopId", () => {
|
||||
const stopId = "177666";
|
||||
it("updates ETA data for stop id if response received", async () => {
|
||||
updateGlobalFetchMockJson(fetchEtaDataSuccessfulResponse);
|
||||
// @ts-ignore
|
||||
const etasFromResponse = fetchEtaDataSuccessfulResponse.ETAs[stopId]
|
||||
updateGlobalFetchMockJson(modifiedSuccessfulResponse);
|
||||
|
||||
await loader.fetchAndUpdateEtaDataForStopId(stopId);
|
||||
// Update shuttles from API
|
||||
await loader.updateShuttleDataForSystemBasedOnProximityToRoutes();
|
||||
|
||||
const etas = await loader.repository.getEtasForStopId(stopId);
|
||||
expect(etas.length).toEqual(etasFromResponse.length);
|
||||
// Old shuttles should be pruned, only API shuttles should remain
|
||||
const shuttles = await loader.shuttleRepository.getShuttles();
|
||||
const busesInResponse = Object.values(modifiedSuccessfulResponse.buses);
|
||||
|
||||
expect(shuttles.length).toEqual(busesInResponse.length);
|
||||
|
||||
// Verify none of the original mock shuttles remain
|
||||
shuttlesToPrune.forEach((originalShuttle) => {
|
||||
const foundShuttle = shuttles.find(s => s.id === originalShuttle.id);
|
||||
expect(foundShuttle).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the correct error if the API response contains no data", async () => {
|
||||
updateGlobalFetchMockJsonToThrowSyntaxError();
|
||||
|
||||
await assertAsyncCallbackThrowsApiResponseError(async () => {
|
||||
await loader.fetchAndUpdateEtaDataForStopId("263");
|
||||
await loader.updateShuttleDataForSystemBasedOnProximityToRoutes();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
import { InterchangeSystemBuilderArguments } from "../entities/InterchangeSystem";
|
||||
import { ChapmanApiBasedParkingRepositoryLoader } from "./parking/ChapmanApiBasedParkingRepositoryLoader";
|
||||
|
||||
export const supportedIntegrationTestSystems: InterchangeSystemBuilderArguments[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Chapman University",
|
||||
passioSystemId: "263",
|
||||
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ShuttleGetterRepository } from "../../repositories/shuttle/ShuttleGetterRepository";
|
||||
import { IEta } from "../../entities/ShuttleRepositoryEntities";
|
||||
import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender";
|
||||
import {
|
||||
@@ -6,11 +5,14 @@ import {
|
||||
ScheduledNotification
|
||||
} from "../../repositories/notifications/NotificationRepository";
|
||||
import { InMemoryNotificationRepository } from "../../repositories/notifications/InMemoryNotificationRepository";
|
||||
import { ETAGetterRepository, ETARepositoryEvent } from "../../repositories/shuttle/eta/ETAGetterRepository";
|
||||
import { ShuttleGetterRepository } from "../../repositories/shuttle/ShuttleGetterRepository";
|
||||
|
||||
export class ETANotificationScheduler {
|
||||
public static readonly defaultSecondsThresholdForNotificationToFire = 180;
|
||||
|
||||
constructor(
|
||||
private etaRepository: ETAGetterRepository,
|
||||
private shuttleRepository: ShuttleGetterRepository,
|
||||
private notificationRepository: NotificationRepository = new InMemoryNotificationRepository(),
|
||||
private appleNotificationSender = new AppleNotificationSender(),
|
||||
@@ -26,7 +28,7 @@ export class ETANotificationScheduler {
|
||||
|
||||
const shuttle = await this.shuttleRepository.getShuttleById(shuttleId);
|
||||
const stop = await this.shuttleRepository.getStopById(stopId);
|
||||
const eta = await this.shuttleRepository.getEtaForShuttleAndStopId(shuttleId, stopId);
|
||||
const eta = await this.etaRepository.getEtaForShuttleAndStopId(shuttleId, stopId);
|
||||
if (!shuttle) {
|
||||
console.warn(`Notification ${notificationData} fell through; no associated shuttle`);
|
||||
return false;
|
||||
@@ -90,10 +92,10 @@ export class ETANotificationScheduler {
|
||||
|
||||
// The following is a workaround for the constructor being called twice
|
||||
public startListeningForUpdates() {
|
||||
this.shuttleRepository.subscribeToEtaUpdates(this.etaSubscriberCallback);
|
||||
this.etaRepository.on(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback);
|
||||
}
|
||||
|
||||
public stopListeningForUpdates() {
|
||||
this.shuttleRepository.subscribeToEtaUpdates(this.etaSubscriberCallback);
|
||||
this.etaRepository.off(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { ETANotificationScheduler } from "../ETANotificationScheduler";
|
||||
import { UnoptimizedInMemoryShuttleRepository } from "../../../repositories/shuttle/UnoptimizedInMemoryShuttleRepository";
|
||||
import { InMemoryExternalSourceETARepository } from "../../../repositories/shuttle/eta/InMemoryExternalSourceETARepository";
|
||||
import { IEta, IShuttle, IStop } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { addMockShuttleToRepository, addMockStopToRepository } from "../../../../testHelpers/repositorySetupHelpers";
|
||||
import { AppleNotificationSender } from "../../senders/AppleNotificationSender";
|
||||
@@ -26,18 +27,21 @@ async function waitForMilliseconds(ms: number): Promise<void> {
|
||||
|
||||
|
||||
describe("ETANotificationScheduler", () => {
|
||||
let shuttleRepository: UnoptimizedInMemoryShuttleRepository
|
||||
let shuttleRepository: UnoptimizedInMemoryShuttleRepository;
|
||||
let etaRepository: InMemoryExternalSourceETARepository;
|
||||
let notificationService: ETANotificationScheduler;
|
||||
let notificationRepository: NotificationRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
shuttleRepository = new UnoptimizedInMemoryShuttleRepository();
|
||||
notificationRepository = new InMemoryNotificationRepository();
|
||||
etaRepository = new InMemoryExternalSourceETARepository();
|
||||
|
||||
mockNotificationSenderMethods(true);
|
||||
|
||||
const appleNotificationSender = new MockAppleNotificationSender(false);
|
||||
notificationService = new ETANotificationScheduler(
|
||||
etaRepository,
|
||||
shuttleRepository,
|
||||
notificationRepository,
|
||||
appleNotificationSender,
|
||||
@@ -80,7 +84,7 @@ describe("ETANotificationScheduler", () => {
|
||||
// Act
|
||||
await notificationRepository.addOrUpdateNotification(notificationData1);
|
||||
await notificationRepository.addOrUpdateNotification(notificationData2);
|
||||
await shuttleRepository.addOrUpdateEta(eta);
|
||||
await etaRepository.addOrUpdateEtaFromExternalSource(eta);
|
||||
|
||||
// Assert
|
||||
// Wait for the callback to actually be called
|
||||
@@ -103,7 +107,7 @@ describe("ETANotificationScheduler", () => {
|
||||
|
||||
// Act
|
||||
await notificationRepository.addOrUpdateNotification(notificationData1);
|
||||
await shuttleRepository.addOrUpdateEta(eta);
|
||||
await etaRepository.addOrUpdateEtaFromExternalSource(eta);
|
||||
|
||||
// Assert
|
||||
await waitForMilliseconds(500);
|
||||
@@ -127,6 +131,7 @@ describe("ETANotificationScheduler", () => {
|
||||
mockNotificationSenderMethods(false);
|
||||
const updatedNotificationSender = new MockAppleNotificationSender(false);
|
||||
notificationService = new ETANotificationScheduler(
|
||||
etaRepository,
|
||||
shuttleRepository,
|
||||
notificationRepository,
|
||||
updatedNotificationSender,
|
||||
@@ -136,7 +141,7 @@ describe("ETANotificationScheduler", () => {
|
||||
|
||||
// Act
|
||||
await notificationRepository.addOrUpdateNotification(notificationData1);
|
||||
await shuttleRepository.addOrUpdateEta(eta);
|
||||
await etaRepository.addOrUpdateEtaFromExternalSource(eta);
|
||||
|
||||
// Assert
|
||||
// The notification should stay scheduled to be retried once
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { createClient } from 'redis';
|
||||
import { REDIS_RECONNECT_INTERVAL } from "../environment";
|
||||
import { RedisClientType } from 'redis';
|
||||
import { EventEmitter } from 'stream';
|
||||
import createRedisClientForRepository from '../helpers/createRedisClientForRepository';
|
||||
|
||||
export abstract class BaseRedisRepository {
|
||||
export abstract class BaseRedisRepository extends EventEmitter {
|
||||
protected redisClient;
|
||||
|
||||
constructor(
|
||||
redisClient = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
socket: {
|
||||
tls: process.env.NODE_ENV === 'production',
|
||||
rejectUnauthorized: false,
|
||||
reconnectStrategy: REDIS_RECONNECT_INTERVAL,
|
||||
},
|
||||
}),
|
||||
redisClient: RedisClientType = createRedisClientForRepository(),
|
||||
) {
|
||||
super();
|
||||
this.redisClient = redisClient;
|
||||
this.redisClient.on('error', (err) => {
|
||||
console.error(err.stack);
|
||||
@@ -31,8 +26,4 @@ export abstract class BaseRedisRepository {
|
||||
public async disconnect() {
|
||||
await this.redisClient.disconnect();
|
||||
}
|
||||
|
||||
public async clearAllData() {
|
||||
await this.redisClient.flushAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { BaseRedisRepository } from "../BaseRedisRepository";
|
||||
|
||||
export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository {
|
||||
private listeners: Listener[] = [];
|
||||
private notificationListeners: Listener[] = [];
|
||||
private readonly NOTIFICATION_KEY_PREFIX = 'notification:';
|
||||
|
||||
private getNotificationKey = (shuttleId: string, stopId: string): string => {
|
||||
@@ -23,7 +23,7 @@ export class RedisNotificationRepository extends BaseRedisRepository implements
|
||||
|
||||
await this.redisClient.hSet(key, deviceId, secondsThreshold.toString());
|
||||
|
||||
this.listeners.forEach((listener: Listener) => {
|
||||
this.notificationListeners.forEach((listener: Listener) => {
|
||||
const event: NotificationEvent = {
|
||||
event: 'addOrUpdate',
|
||||
notification
|
||||
@@ -46,7 +46,7 @@ export class RedisNotificationRepository extends BaseRedisRepository implements
|
||||
await this.redisClient.del(key);
|
||||
}
|
||||
|
||||
this.listeners.forEach((listener) => {
|
||||
this.notificationListeners.forEach((listener) => {
|
||||
const event: NotificationEvent = {
|
||||
event: 'delete',
|
||||
notification: {
|
||||
@@ -94,20 +94,20 @@ export class RedisNotificationRepository extends BaseRedisRepository implements
|
||||
};
|
||||
|
||||
public subscribeToNotificationChanges = (listener: Listener): void => {
|
||||
const index = this.listeners.findIndex(
|
||||
const index = this.notificationListeners.findIndex(
|
||||
(existingListener) => existingListener === listener
|
||||
);
|
||||
if (index < 0) {
|
||||
this.listeners.push(listener);
|
||||
this.notificationListeners.push(listener);
|
||||
}
|
||||
};
|
||||
|
||||
public unsubscribeFromNotificationChanges = (listener: Listener): void => {
|
||||
const index = this.listeners.findIndex(
|
||||
const index = this.notificationListeners.findIndex(
|
||||
(existingListener) => existingListener === listener
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.listeners.splice(index, 1);
|
||||
this.notificationListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import { InMemoryNotificationRepository } from "../InMemoryNotificationRepository";
|
||||
import { NotificationEvent, NotificationRepository } from "../NotificationRepository";
|
||||
import { RedisNotificationRepository } from "../RedisNotificationRepository";
|
||||
@@ -19,17 +20,21 @@ class InMemoryRepositoryHolder implements RepositoryHolder {
|
||||
|
||||
class RedisNotificationRepositoryHolder implements RepositoryHolder {
|
||||
repo: RedisNotificationRepository | undefined;
|
||||
redisClient: RedisClientType | undefined;
|
||||
|
||||
name = 'RedisNotificationRepository';
|
||||
factory = async () => {
|
||||
this.repo = new RedisNotificationRepository();
|
||||
await this.repo.connect();
|
||||
this.redisClient = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
await this.redisClient.connect();
|
||||
this.repo = new RedisNotificationRepository(this.redisClient);
|
||||
return this.repo;
|
||||
}
|
||||
teardown = async () => {
|
||||
if (this.repo) {
|
||||
await this.repo.clearAllData();
|
||||
await this.repo.disconnect();
|
||||
if (this.redisClient) {
|
||||
await this.redisClient.flushAll();
|
||||
await this.redisClient.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository";
|
||||
import {
|
||||
IParkingStructure,
|
||||
IParkingStructureTimestampRecord
|
||||
IParkingStructure,
|
||||
IParkingStructureTimestampRecord
|
||||
} from "../../entities/ParkingRepositoryEntities";
|
||||
import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository";
|
||||
import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository";
|
||||
import { CircularQueue } from "../../types/CircularQueue";
|
||||
import { PARKING_LOGGING_INTERVAL_MS } from "../../environment";
|
||||
|
||||
@@ -63,7 +63,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository
|
||||
return null;
|
||||
};
|
||||
|
||||
getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise<HistoricalParkingAverageQueryResult[]> => {
|
||||
getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise<HistoricalParkingAverageQueryResult[]> => {
|
||||
const queue = this.historicalData.get(id);
|
||||
if (!queue || queue.size() === 0) {
|
||||
return [];
|
||||
@@ -107,7 +107,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository
|
||||
|
||||
private calculateAveragesFromRecords = (
|
||||
records: IParkingStructureTimestampRecord[],
|
||||
options: HistoricalParkingAverageQueryArguments
|
||||
options: HistoricalParkingAverageFilterArguments
|
||||
): HistoricalParkingAverageQueryResult[] => {
|
||||
const results: HistoricalParkingAverageQueryResult[] = [];
|
||||
const { from, to, intervalMs } = options;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
|
||||
|
||||
export interface HistoricalParkingAverageQueryArguments {
|
||||
export interface HistoricalParkingAverageFilterArguments {
|
||||
from: Date;
|
||||
to: Date;
|
||||
intervalMs: number;
|
||||
@@ -22,5 +22,5 @@ export interface ParkingGetterRepository {
|
||||
* @param id
|
||||
* @param options
|
||||
*/
|
||||
getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageQueryArguments): Promise<HistoricalParkingAverageQueryResult[]>;
|
||||
getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageFilterArguments): Promise<HistoricalParkingAverageQueryResult[]>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository";
|
||||
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
|
||||
import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository";
|
||||
import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository";
|
||||
import { BaseRedisRepository } from "../BaseRedisRepository";
|
||||
import { PARKING_LOGGING_INTERVAL_MS } from "../../environment";
|
||||
|
||||
@@ -75,7 +75,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki
|
||||
return null;
|
||||
};
|
||||
|
||||
getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise<HistoricalParkingAverageQueryResult[]> => {
|
||||
getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise<HistoricalParkingAverageQueryResult[]> => {
|
||||
return this.calculateAveragesFromRecords(id, options);
|
||||
};
|
||||
|
||||
@@ -157,7 +157,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki
|
||||
|
||||
private calculateAveragesFromRecords = async (
|
||||
id: string,
|
||||
options: HistoricalParkingAverageQueryArguments
|
||||
options: HistoricalParkingAverageFilterArguments
|
||||
): Promise<HistoricalParkingAverageQueryResult[]> => {
|
||||
const keys = this.createRedisKeys(id);
|
||||
const { from, to, intervalMs } = options;
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import { InMemoryParkingRepository, } from "../InMemoryParkingRepository";
|
||||
import { IParkingStructure } from "../../../entities/ParkingRepositoryEntities";
|
||||
import { HistoricalParkingAverageQueryArguments } from "../ParkingGetterRepository";
|
||||
import { ParkingGetterSetterRepository } from "../ParkingGetterSetterRepository";
|
||||
import { RedisParkingRepository } from "../RedisParkingRepository";
|
||||
import { HistoricalParkingAverageFilterArguments } from "../ParkingGetterRepository";
|
||||
import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder";
|
||||
|
||||
interface RepositoryHolder {
|
||||
name: string;
|
||||
factory(): Promise<ParkingGetterSetterRepository>;
|
||||
teardown(): Promise<void>;
|
||||
}
|
||||
|
||||
class InMemoryParkingRepositoryHolder implements RepositoryHolder {
|
||||
class InMemoryParkingRepositoryHolder implements RepositoryHolder<ParkingGetterSetterRepository> {
|
||||
name = 'InMemoryParkingRepository';
|
||||
factory = async () => {
|
||||
return new InMemoryParkingRepository();
|
||||
@@ -19,19 +15,23 @@ class InMemoryParkingRepositoryHolder implements RepositoryHolder {
|
||||
teardown = async () => {};
|
||||
}
|
||||
|
||||
class RedisParkingRepositoryHolder implements RepositoryHolder {
|
||||
class RedisParkingRepositoryHolder implements RepositoryHolder<ParkingGetterSetterRepository> {
|
||||
repo: RedisParkingRepository | undefined;
|
||||
redisClient: RedisClientType | undefined;
|
||||
|
||||
name = 'RedisParkingRepository';
|
||||
factory = async () => {
|
||||
this.repo = new RedisParkingRepository();
|
||||
await this.repo.connect();
|
||||
this.redisClient = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
await this.redisClient.connect();
|
||||
this.repo = new RedisParkingRepository(this.redisClient);
|
||||
return this.repo;
|
||||
};
|
||||
teardown = async () => {
|
||||
if (this.repo) {
|
||||
await this.repo.clearAllData();
|
||||
await this.repo.disconnect();
|
||||
if (this.redisClient) {
|
||||
await this.redisClient.flushAll();
|
||||
await this.redisClient.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -151,7 +151,7 @@ describe.each(repositoryImplementations)('$name', (holder) => {
|
||||
|
||||
describe("getHistoricalAveragesOfParkingStructureCounts", () => {
|
||||
it("should return empty array for non-existent structure or no data", async () => {
|
||||
const options: HistoricalParkingAverageQueryArguments = {
|
||||
const options: HistoricalParkingAverageFilterArguments = {
|
||||
from: new Date(1000),
|
||||
to: new Date(2000),
|
||||
intervalMs: 500
|
||||
@@ -182,7 +182,7 @@ describe.each(repositoryImplementations)('$name', (holder) => {
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const options: HistoricalParkingAverageQueryArguments = {
|
||||
const options: HistoricalParkingAverageFilterArguments = {
|
||||
from: new Date(now - 10000), // Look back 10 seconds
|
||||
to: new Date(now + 10000), // Look forward 10 seconds
|
||||
intervalMs: 20000 // Single large interval
|
||||
|
||||
629
src/repositories/shuttle/RedisShuttleRepository.ts
Normal file
629
src/repositories/shuttle/RedisShuttleRepository.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository";
|
||||
import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
import {
|
||||
ShuttleRepositoryEvent,
|
||||
ShuttleRepositoryEventListener,
|
||||
ShuttleRepositoryEventName,
|
||||
ShuttleRepositoryEventPayloads,
|
||||
ShuttleStopArrival,
|
||||
ShuttleTravelTimeDataIdentifier,
|
||||
ShuttleTravelTimeDateFilterArguments
|
||||
} from "./ShuttleGetterRepository";
|
||||
import { BaseRedisRepository } from "../BaseRedisRepository";
|
||||
import { RedisClientType } from "redis";
|
||||
import createRedisClientForRepository from "../../helpers/createRedisClientForRepository";
|
||||
|
||||
export class RedisShuttleRepository extends BaseRedisRepository implements ShuttleGetterSetterRepository {
|
||||
constructor(
|
||||
redisClient: RedisClientType = createRedisClientForRepository(),
|
||||
readonly shuttleStopArrivalDegreeDelta: number = 0.001,
|
||||
) {
|
||||
super(redisClient);
|
||||
}
|
||||
|
||||
get isReady() {
|
||||
return this.redisClient.isReady;
|
||||
}
|
||||
|
||||
public async connect() {
|
||||
await this.redisClient.connect();
|
||||
}
|
||||
|
||||
public async disconnect() {
|
||||
await this.redisClient.disconnect();
|
||||
}
|
||||
|
||||
// EventEmitter override methods for type safety
|
||||
public override on<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
public override once<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
public override off<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override off(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
public override addListener<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override addListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.addListener(event, listener);
|
||||
}
|
||||
|
||||
public override removeListener<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override removeListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.removeListener(event, listener);
|
||||
}
|
||||
|
||||
public override emit<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
payload: ShuttleRepositoryEventPayloads[T],
|
||||
): boolean;
|
||||
public override emit(event: string | symbol, ...args: any[]): boolean {
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
// Key prefixes for individual entity keys
|
||||
private readonly stopKeyPrefix = 'shuttle:stop:';
|
||||
private readonly routeKeyPrefix = 'shuttle:route:';
|
||||
private readonly shuttleKeyPrefix = 'shuttle:shuttle:';
|
||||
private readonly orderedStopKeyPrefix = 'shuttle:orderedstop:';
|
||||
private readonly lastStopKeyPrefix = 'shuttle:laststop:';
|
||||
private readonly historicalEtaKeyPrefix = 'shuttle:eta:historical:';
|
||||
|
||||
// Key patterns for bulk operations (e.g., getting all keys, clearing data)
|
||||
private readonly stopKeyPattern = 'shuttle:stop:*';
|
||||
private readonly routeKeyPattern = 'shuttle:route:*';
|
||||
private readonly shuttleKeyPattern = 'shuttle:shuttle:*';
|
||||
private readonly orderedStopKeyPattern = 'shuttle:orderedstop:*';
|
||||
private readonly lastStopKeyPattern = 'shuttle:laststop:*';
|
||||
|
||||
/**
|
||||
* Represents a set storing the shuttles that are currently at a stop.
|
||||
*/
|
||||
private readonly shuttleIsAtStopKey = 'shuttle:atstop';
|
||||
|
||||
// Helper methods for Redis key generation
|
||||
private readonly createStopKey = (stopId: string) => `${this.stopKeyPrefix}${stopId}`;
|
||||
private readonly createRouteKey = (routeId: string) => `${this.routeKeyPrefix}${routeId}`;
|
||||
private readonly createShuttleKey = (shuttleId: string) => `${this.shuttleKeyPrefix}${shuttleId}`;
|
||||
private readonly createOrderedStopKey = (routeId: string, stopId: string) => `${this.orderedStopKeyPrefix}${routeId}:${stopId}`;
|
||||
private readonly createShuttleLastStopKey = (shuttleId: string) => `${this.lastStopKeyPrefix}${shuttleId}`;
|
||||
private readonly createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => {
|
||||
return `${this.historicalEtaKeyPrefix}${routeId}:${fromStopId}:${toStopId}`;
|
||||
};
|
||||
|
||||
// Helper methods for converting entities to Redis hashes
|
||||
private createRedisHashFromStop = (stop: IStop): Record<string, string> => ({
|
||||
id: stop.id,
|
||||
name: stop.name,
|
||||
systemId: stop.systemId,
|
||||
latitude: stop.coordinates.latitude.toString(),
|
||||
longitude: stop.coordinates.longitude.toString(),
|
||||
updatedTime: stop.updatedTime.toISOString(),
|
||||
});
|
||||
|
||||
private createStopFromRedisData = (data: Record<string, string>): IStop => ({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
systemId: data.systemId,
|
||||
coordinates: {
|
||||
latitude: parseFloat(data.latitude),
|
||||
longitude: parseFloat(data.longitude),
|
||||
},
|
||||
updatedTime: new Date(data.updatedTime),
|
||||
});
|
||||
|
||||
private createRedisHashFromRoute = (route: IRoute): Record<string, string> => ({
|
||||
id: route.id,
|
||||
name: route.name,
|
||||
color: route.color,
|
||||
systemId: route.systemId,
|
||||
polylineCoordinates: JSON.stringify(route.polylineCoordinates),
|
||||
updatedTime: route.updatedTime.toISOString(),
|
||||
});
|
||||
|
||||
private createRouteFromRedisData = (data: Record<string, string>): IRoute => ({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
systemId: data.systemId,
|
||||
polylineCoordinates: JSON.parse(data.polylineCoordinates),
|
||||
updatedTime: new Date(data.updatedTime),
|
||||
});
|
||||
|
||||
private createRedisHashFromShuttle = (shuttle: IShuttle): Record<string, string> => ({
|
||||
id: shuttle.id,
|
||||
name: shuttle.name,
|
||||
routeId: shuttle.routeId,
|
||||
systemId: shuttle.systemId,
|
||||
latitude: shuttle.coordinates.latitude.toString(),
|
||||
longitude: shuttle.coordinates.longitude.toString(),
|
||||
orientationInDegrees: shuttle.orientationInDegrees.toString(),
|
||||
updatedTime: shuttle.updatedTime.toISOString(),
|
||||
});
|
||||
|
||||
private createShuttleFromRedisData = (data: Record<string, string>): IShuttle => ({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
routeId: data.routeId,
|
||||
systemId: data.systemId,
|
||||
coordinates: {
|
||||
latitude: parseFloat(data.latitude),
|
||||
longitude: parseFloat(data.longitude),
|
||||
},
|
||||
orientationInDegrees: parseFloat(data.orientationInDegrees),
|
||||
updatedTime: new Date(data.updatedTime),
|
||||
});
|
||||
|
||||
private createEtaFromRedisData = (data: Record<string, string>): IEta => ({
|
||||
secondsRemaining: parseFloat(data.secondsRemaining),
|
||||
shuttleId: data.shuttleId,
|
||||
stopId: data.stopId,
|
||||
systemId: data.systemId,
|
||||
updatedTime: new Date(data.updatedTime),
|
||||
});
|
||||
|
||||
private createRedisHashFromOrderedStop = (orderedStop: IOrderedStop): Record<string, string> => {
|
||||
const hash: Record<string, string> = {
|
||||
routeId: orderedStop.routeId,
|
||||
stopId: orderedStop.stopId,
|
||||
position: orderedStop.position.toString(),
|
||||
systemId: orderedStop.systemId,
|
||||
updatedTime: orderedStop.updatedTime.toISOString(),
|
||||
};
|
||||
|
||||
if (orderedStop.nextStop) {
|
||||
hash.nextStopRouteId = orderedStop.nextStop.routeId;
|
||||
hash.nextStopStopId = orderedStop.nextStop.stopId;
|
||||
}
|
||||
|
||||
if (orderedStop.previousStop) {
|
||||
hash.previousStopRouteId = orderedStop.previousStop.routeId;
|
||||
hash.previousStopStopId = orderedStop.previousStop.stopId;
|
||||
}
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
private createOrderedStopFromRedisData = (data: Record<string, string>): IOrderedStop => {
|
||||
const orderedStop: IOrderedStop = {
|
||||
routeId: data.routeId,
|
||||
stopId: data.stopId,
|
||||
position: parseInt(data.position),
|
||||
systemId: data.systemId,
|
||||
updatedTime: new Date(data.updatedTime),
|
||||
};
|
||||
|
||||
// Note: We only store the IDs of next/previous stops, not full objects
|
||||
// to avoid circular references in Redis. These would need to be
|
||||
// resolved separately if needed.
|
||||
if (data.nextStopRouteId && data.nextStopStopId) {
|
||||
orderedStop.nextStop = {
|
||||
routeId: data.nextStopRouteId,
|
||||
stopId: data.nextStopStopId,
|
||||
position: 0, // placeholder
|
||||
systemId: data.systemId,
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
if (data.previousStopRouteId && data.previousStopStopId) {
|
||||
orderedStop.previousStop = {
|
||||
routeId: data.previousStopRouteId,
|
||||
stopId: data.previousStopStopId,
|
||||
position: 0, // placeholder
|
||||
systemId: data.systemId,
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
return orderedStop;
|
||||
};
|
||||
|
||||
// Getter methods
|
||||
public async getStops(): Promise<IStop[]> {
|
||||
const keys = await this.redisClient.keys(this.stopKeyPattern);
|
||||
const stops: IStop[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
if (Object.keys(data).length > 0) {
|
||||
stops.push(this.createStopFromRedisData(data));
|
||||
}
|
||||
}
|
||||
|
||||
return stops;
|
||||
}
|
||||
|
||||
public async getStopById(stopId: string): Promise<IStop | null> {
|
||||
const key = this.createStopKey(stopId);
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createStopFromRedisData(data);
|
||||
}
|
||||
|
||||
public async getRoutes(): Promise<IRoute[]> {
|
||||
const keys = await this.redisClient.keys(this.routeKeyPattern);
|
||||
const routes: IRoute[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
if (Object.keys(data).length > 0) {
|
||||
routes.push(this.createRouteFromRedisData(data));
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
public async getRouteById(routeId: string): Promise<IRoute | null> {
|
||||
const key = this.createRouteKey(routeId);
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createRouteFromRedisData(data);
|
||||
}
|
||||
|
||||
public async getShuttles(): Promise<IShuttle[]> {
|
||||
const keys = await this.redisClient.keys(this.shuttleKeyPattern);
|
||||
const shuttles: IShuttle[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
if (Object.keys(data).length > 0) {
|
||||
shuttles.push(this.createShuttleFromRedisData(data));
|
||||
}
|
||||
}
|
||||
|
||||
return shuttles;
|
||||
}
|
||||
|
||||
public async getShuttlesByRouteId(routeId: string): Promise<IShuttle[]> {
|
||||
const allShuttles = await this.getShuttles();
|
||||
return allShuttles.filter(shuttle => shuttle.routeId === routeId);
|
||||
}
|
||||
|
||||
public async getShuttleById(shuttleId: string): Promise<IShuttle | null> {
|
||||
const key = this.createShuttleKey(shuttleId);
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createShuttleFromRedisData(data);
|
||||
}
|
||||
|
||||
public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise<IOrderedStop | null> {
|
||||
const key = this.createOrderedStopKey(routeId, stopId);
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createOrderedStopFromRedisData(data);
|
||||
}
|
||||
|
||||
public async getOrderedStopsByStopId(stopId: string): Promise<IOrderedStop[]> {
|
||||
const keys = await this.redisClient.keys(this.orderedStopKeyPattern);
|
||||
const orderedStops: IOrderedStop[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
if (Object.keys(data).length > 0 && data.stopId === stopId) {
|
||||
orderedStops.push(this.createOrderedStopFromRedisData(data));
|
||||
}
|
||||
}
|
||||
|
||||
return orderedStops;
|
||||
}
|
||||
|
||||
public async getOrderedStopsByRouteId(routeId: string): Promise<IOrderedStop[]> {
|
||||
const keys = await this.redisClient.keys(`${this.orderedStopKeyPrefix}${routeId}:*`);
|
||||
const orderedStops: IOrderedStop[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
if (Object.keys(data).length > 0) {
|
||||
orderedStops.push(this.createOrderedStopFromRedisData(data));
|
||||
}
|
||||
}
|
||||
|
||||
return orderedStops;
|
||||
}
|
||||
|
||||
// Setter/update methods
|
||||
public async addOrUpdateRoute(route: IRoute): Promise<void> {
|
||||
const key = this.createRouteKey(route.id);
|
||||
await this.redisClient.hSet(key, this.createRedisHashFromRoute(route));
|
||||
}
|
||||
|
||||
public async addOrUpdateShuttle(
|
||||
shuttle: IShuttle,
|
||||
travelTimeTimestamp = Date.now(),
|
||||
): Promise<void> {
|
||||
const key = this.createShuttleKey(shuttle.id);
|
||||
await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle));
|
||||
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_UPDATED, shuttle);
|
||||
|
||||
await this.updateLastStopArrival(shuttle, travelTimeTimestamp);
|
||||
}
|
||||
|
||||
private async updateLastStopArrival(
|
||||
shuttle: IShuttle,
|
||||
travelTimeTimestamp = Date.now(),
|
||||
) {
|
||||
const isAtStop = await this.checkIfShuttleIsAtStop(shuttle.id);
|
||||
|
||||
let arrivedStop: IStop | undefined;
|
||||
|
||||
if (isAtStop) {
|
||||
// Allow retrieval of the shuttle's current stop
|
||||
// Will still return undefined when the shuttle leaves the stop
|
||||
arrivedStop = await this.getArrivedStopIfNextStop(shuttle, true);
|
||||
} else {
|
||||
arrivedStop = await this.getArrivedStopIfNextStop(shuttle, false);
|
||||
}
|
||||
|
||||
// Will not fire *any* events if the same stop
|
||||
const lastStop = await this.getShuttleLastStopArrival(shuttle.id);
|
||||
if (lastStop?.stopId === arrivedStop?.id) return;
|
||||
|
||||
if (isAtStop) {
|
||||
if (lastStop) {
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, {
|
||||
stopArrivalThatShuttleIsLeaving: lastStop,
|
||||
});
|
||||
}
|
||||
await this.markShuttleAsNotAtStop(shuttle.id);
|
||||
}
|
||||
|
||||
if (arrivedStop) {
|
||||
const shuttleArrival = {
|
||||
stopId: arrivedStop.id,
|
||||
timestamp: new Date(travelTimeTimestamp),
|
||||
shuttleId: shuttle.id,
|
||||
};
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, {
|
||||
lastStopArrival: lastStop,
|
||||
willArriveAt: shuttleArrival,
|
||||
});
|
||||
await this.markShuttleAsAtStop(shuttleArrival.shuttleId);
|
||||
await this.updateShuttleLastStopArrival(shuttleArrival);
|
||||
}
|
||||
}
|
||||
|
||||
private async markShuttleAsAtStop(shuttleId: string) {
|
||||
await this.redisClient.sAdd(this.shuttleIsAtStopKey, shuttleId);
|
||||
}
|
||||
|
||||
private async markShuttleAsNotAtStop(shuttleId: string) {
|
||||
await this.redisClient.sRem(this.shuttleIsAtStopKey, shuttleId);
|
||||
}
|
||||
|
||||
public async checkIfShuttleIsAtStop(shuttleId: string) {
|
||||
return await this.redisClient.sIsMember(this.shuttleIsAtStopKey, shuttleId);
|
||||
}
|
||||
|
||||
public async getAverageTravelTimeSeconds(
|
||||
{ routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier,
|
||||
{ from, to }: ShuttleTravelTimeDateFilterArguments,
|
||||
): Promise<number | undefined> {
|
||||
const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId);
|
||||
const fromTimestamp = from.getTime();
|
||||
const toTimestamp = to.getTime();
|
||||
const intervalMs = toTimestamp - fromTimestamp + 1;
|
||||
|
||||
try {
|
||||
const aggregationResult = await this.redisClient.sendCommand([
|
||||
'TS.RANGE',
|
||||
timeSeriesKey,
|
||||
fromTimestamp.toString(),
|
||||
toTimestamp.toString(),
|
||||
'AGGREGATION',
|
||||
'AVG',
|
||||
intervalMs.toString()
|
||||
]) as [string, string][];
|
||||
|
||||
if (aggregationResult && aggregationResult.length > 0) {
|
||||
const [, averageValue] = aggregationResult[0];
|
||||
return parseFloat(averageValue);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async getArrivedStopIfNextStop(
|
||||
shuttle: IShuttle,
|
||||
canReturnShuttleCurrentStop: boolean = false,
|
||||
): Promise<IStop | undefined> {
|
||||
const degreeDelta = this.shuttleStopArrivalDegreeDelta;
|
||||
|
||||
const lastStopArrival = await this.getShuttleLastStopArrival(shuttle.id);
|
||||
if (lastStopArrival) {
|
||||
// Return the shuttle's current stop depending on the flag
|
||||
if (canReturnShuttleCurrentStop) {
|
||||
const lastStop = await this.getStopById(lastStopArrival.stopId);
|
||||
if (lastStop && shuttleHasArrivedAtStop(shuttle, lastStop, degreeDelta)) {
|
||||
return lastStop;
|
||||
}
|
||||
}
|
||||
|
||||
const lastOrderedStop = await this.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStopArrival.stopId);
|
||||
const orderedStopAfter = lastOrderedStop?.nextStop;
|
||||
if (orderedStopAfter) {
|
||||
const stopAfter = await this.getStopById(orderedStopAfter.stopId);
|
||||
if (stopAfter && shuttleHasArrivedAtStop(shuttle, stopAfter, degreeDelta)) {
|
||||
return stopAfter;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const orderedStops = await this.getOrderedStopsByRouteId(shuttle.routeId);
|
||||
|
||||
for (const orderedStop of orderedStops) {
|
||||
const stop = await this.getStopById(orderedStop.stopId);
|
||||
if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, degreeDelta)) {
|
||||
return stop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async getShuttleLastStopArrival(shuttleId: string): Promise<ShuttleStopArrival | undefined> {
|
||||
const key = this.createShuttleLastStopKey(shuttleId);
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
shuttleId,
|
||||
stopId: data.stopId,
|
||||
timestamp: new Date(data.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
private async updateShuttleLastStopArrival(lastStopArrival: ShuttleStopArrival) {
|
||||
const key = this.createShuttleLastStopKey(lastStopArrival.shuttleId);
|
||||
await this.redisClient.hSet(key, {
|
||||
stopId: lastStopArrival.stopId,
|
||||
timestamp: lastStopArrival.timestamp.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
public async addOrUpdateStop(stop: IStop): Promise<void> {
|
||||
const key = this.createStopKey(stop.id);
|
||||
await this.redisClient.hSet(key, this.createRedisHashFromStop(stop));
|
||||
}
|
||||
|
||||
public async addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void> {
|
||||
const key = this.createOrderedStopKey(orderedStop.routeId, orderedStop.stopId);
|
||||
await this.redisClient.hSet(key, this.createRedisHashFromOrderedStop(orderedStop));
|
||||
}
|
||||
|
||||
// Remove methods
|
||||
public async removeRouteIfExists(routeId: string): Promise<IRoute | null> {
|
||||
const route = await this.getRouteById(routeId);
|
||||
if (route) {
|
||||
const key = this.createRouteKey(routeId);
|
||||
await this.redisClient.del(key);
|
||||
return route;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async removeShuttleIfExists(shuttleId: string): Promise<IShuttle | null> {
|
||||
const shuttle = await this.getShuttleById(shuttleId);
|
||||
if (shuttle) {
|
||||
const key = this.createShuttleKey(shuttleId);
|
||||
await this.redisClient.del(key);
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_REMOVED, shuttle);
|
||||
|
||||
await this.removeShuttleLastStopIfExists(shuttleId);
|
||||
await this.markShuttleAsNotAtStop(shuttleId);
|
||||
|
||||
return shuttle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async removeStopIfExists(stopId: string): Promise<IStop | null> {
|
||||
const stop = await this.getStopById(stopId);
|
||||
if (stop) {
|
||||
const key = this.createStopKey(stopId);
|
||||
await this.redisClient.del(key);
|
||||
return stop;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async removeOrderedStopIfExists(stopId: string, routeId: string): Promise<IOrderedStop | null> {
|
||||
const orderedStop = await this.getOrderedStopByRouteAndStopId(routeId, stopId);
|
||||
if (orderedStop) {
|
||||
const key = this.createOrderedStopKey(routeId, stopId);
|
||||
await this.redisClient.del(key);
|
||||
return orderedStop;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async removeShuttleLastStopIfExists(shuttleId: string) {
|
||||
const lastStop = await this.getShuttleLastStopArrival(shuttleId);
|
||||
if (lastStop) {
|
||||
const key = this.createShuttleLastStopKey(shuttleId);
|
||||
await this.redisClient.del(key);
|
||||
return lastStop;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clear methods
|
||||
private async clearRedisKeys(pattern: string): Promise<void> {
|
||||
const keys = await this.redisClient.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await this.redisClient.del(keys);
|
||||
}
|
||||
}
|
||||
|
||||
public async clearShuttleData(): Promise<void> {
|
||||
await this.clearRedisKeys(this.shuttleKeyPattern);
|
||||
await this.clearShuttleLastStopData();
|
||||
await this.clearShuttleIsAtStopData();
|
||||
}
|
||||
|
||||
public async clearOrderedStopData(): Promise<void> {
|
||||
await this.clearRedisKeys(this.orderedStopKeyPattern);
|
||||
}
|
||||
|
||||
public async clearRouteData(): Promise<void> {
|
||||
await this.clearRedisKeys(this.routeKeyPattern);
|
||||
}
|
||||
|
||||
public async clearStopData(): Promise<void> {
|
||||
await this.clearRedisKeys(this.stopKeyPattern);
|
||||
}
|
||||
|
||||
private async clearShuttleLastStopData(): Promise<void> {
|
||||
await this.clearRedisKeys(this.lastStopKeyPattern);
|
||||
}
|
||||
|
||||
private async clearShuttleIsAtStopData(): Promise<void> {
|
||||
await this.clearRedisKeys(this.shuttleIsAtStopKey);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,61 @@
|
||||
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
import type EventEmitter from "node:events";
|
||||
|
||||
export const ShuttleRepositoryEvent = {
|
||||
SHUTTLE_UPDATED: "shuttleUpdated",
|
||||
SHUTTLE_REMOVED: "shuttleRemoved",
|
||||
SHUTTLE_WILL_ARRIVE_AT_STOP: "shuttleArrivedAtStop",
|
||||
SHUTTLE_WILL_LEAVE_STOP: "shuttleWillLeaveStop",
|
||||
} as const;
|
||||
|
||||
export type ShuttleRepositoryEventName = typeof ShuttleRepositoryEvent[keyof typeof ShuttleRepositoryEvent];
|
||||
|
||||
export type EtaRemovedEventPayload = IEta;
|
||||
export type EtaDataClearedEventPayload = IEta[];
|
||||
|
||||
export interface ShuttleWillArriveAtStopPayload {
|
||||
lastStopArrival?: ShuttleStopArrival;
|
||||
willArriveAt: ShuttleStopArrival;
|
||||
};
|
||||
|
||||
export type ShuttleIsNearStopPayload = ShuttleWillArriveAtStopPayload;
|
||||
|
||||
export interface ShuttleWillLeaveStopPayload {
|
||||
stopArrivalThatShuttleIsLeaving: ShuttleStopArrival;
|
||||
}
|
||||
|
||||
export interface ShuttleRepositoryEventPayloads {
|
||||
[ShuttleRepositoryEvent.SHUTTLE_UPDATED]: IShuttle,
|
||||
[ShuttleRepositoryEvent.SHUTTLE_REMOVED]: IShuttle,
|
||||
[ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP]: ShuttleWillArriveAtStopPayload,
|
||||
[ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP]: ShuttleWillLeaveStopPayload,
|
||||
}
|
||||
|
||||
export type ShuttleRepositoryEventListener<T extends ShuttleRepositoryEventName> = (
|
||||
payload: ShuttleRepositoryEventPayloads[T],
|
||||
) => void;
|
||||
|
||||
export interface ShuttleStopArrival {
|
||||
shuttleId: string;
|
||||
stopId: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ShuttleTravelTimeDataIdentifier {
|
||||
routeId: string;
|
||||
fromStopId: string;
|
||||
toStopId: string;
|
||||
}
|
||||
|
||||
export interface ShuttleTravelTimeDateFilterArguments {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuttle getter repository to be linked to a system.
|
||||
*/
|
||||
export interface ShuttleGetterRepository {
|
||||
export interface ShuttleGetterRepository extends EventEmitter {
|
||||
getStops(): Promise<IStop[]>;
|
||||
getStopById(stopId: string): Promise<IStop | null>;
|
||||
|
||||
@@ -14,27 +66,11 @@ export interface ShuttleGetterRepository {
|
||||
getShuttleById(shuttleId: string): Promise<IShuttle | null>;
|
||||
getShuttlesByRouteId(routeId: string): Promise<IShuttle[]>;
|
||||
|
||||
getEtasForShuttleId(shuttleId: string): Promise<IEta[]>;
|
||||
getEtasForStopId(stopId: string): Promise<IEta[]>;
|
||||
getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise<IEta | null>;
|
||||
|
||||
/**
|
||||
* Subscribe to all updates in ETA data.
|
||||
* The subscriber persists even if the ETA data does not
|
||||
* exist within the repository, and may fire again
|
||||
* if ETA data is restored.
|
||||
* @param listener
|
||||
*/
|
||||
subscribeToEtaUpdates(
|
||||
listener: (eta: IEta) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unsubscribe from all ETA updates for the given callback.
|
||||
* Callback must be passed by reference.
|
||||
* @param listener
|
||||
*/
|
||||
unsubscribeFromEtaUpdates(listener: (eta: IEta) => void): void;
|
||||
on<T extends ShuttleRepositoryEventName>(event: T, listener: ShuttleRepositoryEventListener<T>): this;
|
||||
once<T extends ShuttleRepositoryEventName>(event: T, listener: ShuttleRepositoryEventListener<T>): this;
|
||||
off<T extends ShuttleRepositoryEventName>(event: T, listener: ShuttleRepositoryEventListener<T>): this;
|
||||
addListener<T extends ShuttleRepositoryEventName>(event: T, listener: ShuttleRepositoryEventListener<T>): this;
|
||||
removeListener<T extends ShuttleRepositoryEventName>(event: T, listener: ShuttleRepositoryEventListener<T>): this;
|
||||
|
||||
getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise<IOrderedStop | null>;
|
||||
|
||||
@@ -51,4 +87,33 @@ export interface ShuttleGetterRepository {
|
||||
* @param routeId
|
||||
*/
|
||||
getOrderedStopsByRouteId(routeId: string): Promise<IOrderedStop[]>;
|
||||
|
||||
/**
|
||||
* Get the last stop arrival for a shuttle.
|
||||
* Returns undefined if no last stop arrival has been recorded.
|
||||
* @param shuttleId
|
||||
*/
|
||||
getShuttleLastStopArrival(shuttleId: string): Promise<ShuttleStopArrival | undefined>;
|
||||
|
||||
/**
|
||||
* Determine if the shuttle is currently at a stop.
|
||||
* If `true`, then calling `getShuttleLastStopArrival` will get
|
||||
* the stop the shuttle is currently at.
|
||||
* @param shuttleId
|
||||
*/
|
||||
checkIfShuttleIsAtStop(shuttleId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the stop that the shuttle is currently at, if it's the shuttle's
|
||||
* next stop based on the "last stop" the shuttle was at. If there was no
|
||||
* "last stop" for the shuttle, it may return any stop on the shuttle's route.
|
||||
*
|
||||
* @param shuttle
|
||||
* @param canReturnShuttleCurrentStop If set to true, and the shuttle's "last stop"
|
||||
* matches the arrived stop, continue to return the arrived stop.
|
||||
* Otherwise, only return the shuttle's next stop.
|
||||
* This flag has no effect if the shuttle has not had a "last stop".
|
||||
* @returns
|
||||
*/
|
||||
getArrivedStopIfNextStop(shuttle: IShuttle, canReturnShuttleCurrentStop: boolean): Promise<IStop | undefined>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// If types match closely, we can use TypeScript "casting"
|
||||
// to convert from data repo to GraphQL schema
|
||||
|
||||
import { ShuttleGetterRepository } from "./ShuttleGetterRepository";
|
||||
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "./ShuttleGetterRepository";
|
||||
import { IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
|
||||
/**
|
||||
* ShuttleGetterRepository interface for data derived from Passio API.
|
||||
@@ -13,20 +13,28 @@ import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/Shut
|
||||
export interface ShuttleGetterSetterRepository extends ShuttleGetterRepository {
|
||||
// Setter methods
|
||||
addOrUpdateRoute(route: IRoute): Promise<void>;
|
||||
addOrUpdateShuttle(shuttle: IShuttle): Promise<void>;
|
||||
addOrUpdateShuttle(shuttle: IShuttle, travelTimeTimestamp?: number, referenceCurrentTime?: Date): Promise<void>;
|
||||
addOrUpdateStop(stop: IStop): Promise<void>;
|
||||
addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void>;
|
||||
addOrUpdateEta(eta: IEta): Promise<void>;
|
||||
|
||||
removeRouteIfExists(routeId: string): Promise<IRoute | null>;
|
||||
removeShuttleIfExists(shuttleId: string): Promise<IShuttle | null>;
|
||||
removeStopIfExists(stopId: string): Promise<IStop | null>;
|
||||
removeOrderedStopIfExists(stopId: string, routeId: string): Promise<IOrderedStop | null>;
|
||||
removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null>;
|
||||
|
||||
clearRouteData(): Promise<void>;
|
||||
clearShuttleData(): Promise<void>;
|
||||
clearStopData(): Promise<void>;
|
||||
clearOrderedStopData(): Promise<void>;
|
||||
clearEtaData(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get average travel time between two stops based on historical data.
|
||||
* Returns undefined if no data exists for the specified time range.
|
||||
* @param identifier - The route and stop IDs to query
|
||||
* @param dateFilter - The date range to filter data
|
||||
*/
|
||||
getAverageTravelTimeSeconds(
|
||||
identifier: ShuttleTravelTimeDataIdentifier,
|
||||
dateFilter: ShuttleTravelTimeDateFilterArguments
|
||||
): Promise<number | undefined>;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,87 @@
|
||||
import EventEmitter from "node:events";
|
||||
import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository";
|
||||
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
import { IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
import { IEntityWithId } from "../../entities/SharedEntities";
|
||||
import {
|
||||
ShuttleRepositoryEvent,
|
||||
ShuttleRepositoryEventListener,
|
||||
ShuttleRepositoryEventName,
|
||||
ShuttleRepositoryEventPayloads,
|
||||
ShuttleStopArrival,
|
||||
ShuttleTravelTimeDataIdentifier,
|
||||
ShuttleTravelTimeDateFilterArguments,
|
||||
} from "./ShuttleGetterRepository";
|
||||
|
||||
/**
|
||||
* An unoptimized in memory repository.
|
||||
* (I would optimize it with actual data structures, but I'm
|
||||
* switching to another data store later anyways)
|
||||
*/
|
||||
export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetterRepository {
|
||||
export class UnoptimizedInMemoryShuttleRepository
|
||||
extends EventEmitter
|
||||
implements ShuttleGetterSetterRepository {
|
||||
|
||||
constructor(
|
||||
readonly shuttleStopArrivalDegreeDelta: number = 0.001,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public override on<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
public override once<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
public override off<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override off(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
public override addListener<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override addListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.addListener(event, listener);
|
||||
}
|
||||
|
||||
public override removeListener<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
listener: ShuttleRepositoryEventListener<T>,
|
||||
): this;
|
||||
public override removeListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.removeListener(event, listener);
|
||||
}
|
||||
|
||||
public override emit<T extends ShuttleRepositoryEventName>(
|
||||
event: T,
|
||||
payload: ShuttleRepositoryEventPayloads[T],
|
||||
): boolean;
|
||||
public override emit(event: string | symbol, ...args: any[]): boolean {
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
private stops: IStop[] = [];
|
||||
private routes: IRoute[] = [];
|
||||
private shuttles: IShuttle[] = [];
|
||||
private etas: IEta[] = [];
|
||||
private orderedStops: IOrderedStop[] = [];
|
||||
|
||||
private subscribers: ((eta: IEta) => void)[] = [];
|
||||
private shuttleLastStopArrivals: Map<string, ShuttleStopArrival> = new Map();
|
||||
private travelTimeData: Map<string, Array<{ timestamp: number; seconds: number }>> = new Map();
|
||||
private shuttlesAtStop: Set<string> = new Set();
|
||||
|
||||
public async getStops(): Promise<IStop[]> {
|
||||
return this.stops;
|
||||
@@ -44,29 +111,6 @@ export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetter
|
||||
return this.findEntityById(shuttleId, this.shuttles);
|
||||
}
|
||||
|
||||
public async getEtasForShuttleId(shuttleId: string): Promise<IEta[]> {
|
||||
return this.etas.filter(eta => eta.shuttleId === shuttleId);
|
||||
}
|
||||
|
||||
public async getEtasForStopId(stopId: string) {
|
||||
return this.etas.filter(eta => eta.stopId === stopId);
|
||||
}
|
||||
|
||||
public subscribeToEtaUpdates(listener: (eta: IEta) => void) {
|
||||
this.subscribers.push(listener);
|
||||
}
|
||||
|
||||
public unsubscribeFromEtaUpdates(listener: (eta: IEta) => void) {
|
||||
const index = this.subscribers.findIndex((existingListener) => existingListener == listener);
|
||||
if (index >= 0) {
|
||||
this.subscribers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string) {
|
||||
return this.findEntityByMatcher<IEta>((value) => value.stopId === stopId && value.shuttleId === shuttleId, this.etas);
|
||||
}
|
||||
|
||||
public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string) {
|
||||
return this.findEntityByMatcher<IOrderedStop>((value) => value.routeId === routeId && value.stopId === stopId, this.orderedStops)
|
||||
}
|
||||
@@ -100,13 +144,20 @@ export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetter
|
||||
}
|
||||
}
|
||||
|
||||
public async addOrUpdateShuttle(shuttle: IShuttle): Promise<void> {
|
||||
public async addOrUpdateShuttle(
|
||||
shuttle: IShuttle,
|
||||
travelTimeTimestamp = Date.now(),
|
||||
): Promise<void> {
|
||||
const index = this.shuttles.findIndex((s) => s.id === shuttle.id);
|
||||
if (index !== -1) {
|
||||
this.shuttles[index] = shuttle;
|
||||
} else {
|
||||
this.shuttles.push(shuttle);
|
||||
}
|
||||
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_UPDATED, shuttle);
|
||||
|
||||
await this.updateLastStopArrival(shuttle, travelTimeTimestamp);
|
||||
}
|
||||
|
||||
public async addOrUpdateStop(stop: IStop): Promise<void> {
|
||||
@@ -127,20 +178,135 @@ export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetter
|
||||
}
|
||||
}
|
||||
|
||||
public async addOrUpdateEta(eta: IEta): Promise<void> {
|
||||
const index = this.etas.findIndex((e) => e.stopId === eta.stopId && e.shuttleId === eta.shuttleId);
|
||||
if (index !== -1) {
|
||||
this.etas[index] = eta;
|
||||
private async updateLastStopArrival(
|
||||
shuttle: IShuttle,
|
||||
travelTimeTimestamp = Date.now(),
|
||||
) {
|
||||
const isAtStop = await this.checkIfShuttleIsAtStop(shuttle.id);
|
||||
|
||||
let arrivedStop: IStop | undefined;
|
||||
|
||||
if (isAtStop) {
|
||||
// Allow retrieval of the shuttle's current stop
|
||||
// Will still return undefined when the shuttle leaves the stop
|
||||
arrivedStop = await this.getArrivedStopIfNextStop(shuttle, true);
|
||||
} else {
|
||||
this.etas.push(eta);
|
||||
arrivedStop = await this.getArrivedStopIfNextStop(shuttle, false);
|
||||
}
|
||||
|
||||
|
||||
// Will not fire *any* events if the same stop
|
||||
const lastStop = await this.getShuttleLastStopArrival(shuttle.id);
|
||||
if (lastStop?.stopId === arrivedStop?.id) return;
|
||||
|
||||
if (isAtStop) {
|
||||
if (lastStop) {
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, {
|
||||
stopArrivalThatShuttleIsLeaving: lastStop,
|
||||
});
|
||||
}
|
||||
await this.markShuttleAsNotAtStop(shuttle.id);
|
||||
}
|
||||
|
||||
if (arrivedStop) {
|
||||
// stop if same stop
|
||||
const shuttleArrival = {
|
||||
stopId: arrivedStop.id,
|
||||
timestamp: new Date(travelTimeTimestamp),
|
||||
shuttleId: shuttle.id,
|
||||
};
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, {
|
||||
lastStopArrival: lastStop,
|
||||
willArriveAt: shuttleArrival,
|
||||
});
|
||||
await this.markShuttleAsAtStop(shuttleArrival.shuttleId);
|
||||
await this.updateShuttleLastStopArrival(shuttleArrival);
|
||||
}
|
||||
this.publishEtaUpdateToSubscribers(eta);
|
||||
}
|
||||
|
||||
private publishEtaUpdateToSubscribers(eta: IEta) {
|
||||
this.subscribers.forEach(subscriber => {
|
||||
subscriber(eta);
|
||||
});
|
||||
private async markShuttleAsAtStop(shuttleId: string) {
|
||||
this.shuttlesAtStop.add(shuttleId);
|
||||
}
|
||||
|
||||
private async markShuttleAsNotAtStop(shuttleId: string) {
|
||||
this.shuttlesAtStop.delete(shuttleId);
|
||||
}
|
||||
|
||||
public async checkIfShuttleIsAtStop(shuttleId: string) {
|
||||
return this.shuttlesAtStop.has(shuttleId);
|
||||
}
|
||||
|
||||
|
||||
private async updateShuttleLastStopArrival(lastStopArrival: ShuttleStopArrival) {
|
||||
this.shuttleLastStopArrivals.set(lastStopArrival.shuttleId, lastStopArrival);
|
||||
}
|
||||
|
||||
public async getAverageTravelTimeSeconds(
|
||||
{ routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier,
|
||||
{ from, to }: ShuttleTravelTimeDateFilterArguments,
|
||||
): Promise<number | undefined> {
|
||||
const key = `${routeId}:${fromStopId}:${toStopId}`;
|
||||
const dataPoints = this.travelTimeData.get(key);
|
||||
|
||||
if (!dataPoints || dataPoints.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fromTimestamp = from.getTime();
|
||||
const toTimestamp = to.getTime();
|
||||
|
||||
const filteredPoints = dataPoints.filter(
|
||||
(point) => point.timestamp >= fromTimestamp && point.timestamp <= toTimestamp
|
||||
);
|
||||
|
||||
if (filteredPoints.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sum = filteredPoints.reduce((acc, point) => acc + point.seconds, 0);
|
||||
return sum / filteredPoints.length;
|
||||
}
|
||||
|
||||
public async getArrivedStopIfNextStop(
|
||||
shuttle: IShuttle,
|
||||
canReturnShuttleCurrentStop: boolean = false,
|
||||
): Promise<IStop | undefined> {
|
||||
const degreeDelta = this.shuttleStopArrivalDegreeDelta;
|
||||
|
||||
const lastStopArrival = await this.getShuttleLastStopArrival(shuttle.id);
|
||||
if (lastStopArrival) {
|
||||
// Return the shuttle's current stop depending on the flag
|
||||
if (canReturnShuttleCurrentStop) {
|
||||
const lastStop = await this.getStopById(lastStopArrival.stopId);
|
||||
if (lastStop && shuttleHasArrivedAtStop(shuttle, lastStop, degreeDelta)) {
|
||||
return lastStop;
|
||||
}
|
||||
}
|
||||
|
||||
const lastOrderedStop = await this.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStopArrival.stopId);
|
||||
const orderedStopAfter = lastOrderedStop?.nextStop;
|
||||
if (orderedStopAfter) {
|
||||
const stopAfter = await this.getStopById(orderedStopAfter.stopId);
|
||||
if (stopAfter && shuttleHasArrivedAtStop(shuttle, stopAfter, degreeDelta)) {
|
||||
return stopAfter;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const orderedStops = await this.getOrderedStopsByRouteId(shuttle.routeId);
|
||||
|
||||
for (const orderedStop of orderedStops) {
|
||||
const stop = await this.getStopById(orderedStop.stopId);
|
||||
if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, degreeDelta)) {
|
||||
return stop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async getShuttleLastStopArrival(shuttleId: string): Promise<ShuttleStopArrival | undefined> {
|
||||
return this.shuttleLastStopArrivals.get(shuttleId);
|
||||
}
|
||||
|
||||
private async removeEntityByMatcherIfExists<T>(callback: (value: T) => boolean, arrayToSearchIn: T[]) {
|
||||
@@ -163,7 +329,13 @@ export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetter
|
||||
}
|
||||
|
||||
public async removeShuttleIfExists(shuttleId: string): Promise<IShuttle | null> {
|
||||
return await this.removeEntityByIdIfExists(shuttleId, this.shuttles);
|
||||
const shuttle = await this.removeEntityByIdIfExists(shuttleId, this.shuttles);
|
||||
if (shuttle != null) {
|
||||
this.emit(ShuttleRepositoryEvent.SHUTTLE_REMOVED, shuttle);
|
||||
this.shuttlesAtStop.delete(shuttleId);
|
||||
await this.removeShuttleLastStopIfExists(shuttleId);
|
||||
}
|
||||
return shuttle;
|
||||
}
|
||||
|
||||
public async removeStopIfExists(stopId: string): Promise<IStop | null> {
|
||||
@@ -177,19 +349,14 @@ export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetter
|
||||
}, this.orderedStops);
|
||||
}
|
||||
|
||||
public async removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null> {
|
||||
return await this.removeEntityByMatcherIfExists((eta) => {
|
||||
return eta.stopId === stopId
|
||||
&& eta.shuttleId === shuttleId
|
||||
}, this.etas);
|
||||
private async removeShuttleLastStopIfExists(shuttleId: string) {
|
||||
this.shuttleLastStopArrivals.delete(shuttleId);
|
||||
}
|
||||
|
||||
public async clearShuttleData(): Promise<void> {
|
||||
this.shuttles = [];
|
||||
}
|
||||
|
||||
public async clearEtaData(): Promise<void> {
|
||||
this.etas = [];
|
||||
this.shuttlesAtStop.clear();
|
||||
await this.clearShuttleLastStopData();
|
||||
}
|
||||
|
||||
public async clearOrderedStopData(): Promise<void> {
|
||||
@@ -204,4 +371,7 @@ export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetter
|
||||
this.stops = [];
|
||||
}
|
||||
|
||||
private async clearShuttleLastStopData(): Promise<void> {
|
||||
this.shuttleLastStopArrivals.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShuttleRepository";
|
||||
import { ShuttleGetterSetterRepository } from "../ShuttleGetterSetterRepository";
|
||||
import { RedisShuttleRepository } from "../RedisShuttleRepository";
|
||||
import {
|
||||
generateMockOrderedStops,
|
||||
generateMockRoutes,
|
||||
generateMockShuttles,
|
||||
generateMockStops,
|
||||
} from "../../../../testHelpers/mockDataGenerators";
|
||||
import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder";
|
||||
import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository";
|
||||
import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository";
|
||||
|
||||
class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder<ShuttleGetterSetterRepository> {
|
||||
name = 'UnoptimizedInMemoryShuttleRepository';
|
||||
factory = async () => {
|
||||
return new UnoptimizedInMemoryShuttleRepository();
|
||||
};
|
||||
teardown = async () => { };
|
||||
}
|
||||
|
||||
class RedisShuttleRepositoryHolder implements RepositoryHolder<ShuttleGetterSetterRepository> {
|
||||
repo: RedisShuttleRepository | undefined;
|
||||
redisClient: RedisClientType | undefined;
|
||||
|
||||
name = 'RedisShuttleRepository';
|
||||
factory = async () => {
|
||||
this.redisClient = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
await this.redisClient.connect();
|
||||
this.repo = new RedisShuttleRepository(this.redisClient);
|
||||
return this.repo;
|
||||
};
|
||||
teardown = async () => {
|
||||
if (this.redisClient) {
|
||||
await this.redisClient.flushAll();
|
||||
await this.redisClient.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const repositoryImplementations = [
|
||||
new UnoptimizedInMemoryShuttleRepositoryHolder(),
|
||||
new RedisShuttleRepositoryHolder(),
|
||||
];
|
||||
|
||||
describe.each(repositoryImplementations)('$name', (holder) => {
|
||||
let repository: ShuttleGetterSetterRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = await holder.factory();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await holder.teardown();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("getStops", () => {
|
||||
test("gets all stops in the repository", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
for (const stop of mockStops) {
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toHaveLength(mockStops.length);
|
||||
expect(result).toEqual(expect.arrayContaining(mockStops));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no stops for the given system ID", async () => {
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStopById", () => {
|
||||
test("gets a stop by ID if it exists", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
const mockStop = mockStops[0];
|
||||
await repository.addOrUpdateStop(mockStop);
|
||||
|
||||
const result = await repository.getStopById("st1");
|
||||
expect(result).toEqual(mockStop);
|
||||
});
|
||||
|
||||
test("returns null if the stop does not exist", async () => {
|
||||
const result = await repository.getStopById("nonexistent-stop");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoutes", () => {
|
||||
test("gets all routes for a specific system ID", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
for (const route of mockRoutes) {
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toHaveLength(mockRoutes.length);
|
||||
expect(result).toEqual(expect.arrayContaining(mockRoutes));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no routes for the system ID", async () => {
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRouteById", () => {
|
||||
test("gets a route by ID if it exists", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
const mockRoute = mockRoutes[0];
|
||||
await repository.addOrUpdateRoute(mockRoute);
|
||||
|
||||
const result = await repository.getRouteById("r1");
|
||||
expect(result).toEqual(mockRoute);
|
||||
});
|
||||
|
||||
test("returns null if the route does not exist", async () => {
|
||||
const result = await repository.getRouteById("nonexistent-route");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShuttles", () => {
|
||||
test("gets all shuttles for a specific system ID", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toHaveLength(mockShuttles.length);
|
||||
expect(result).toEqual(expect.arrayContaining(mockShuttles));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no shuttles for the system ID", async () => {
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShuttlesByRouteId", () => {
|
||||
test("gets all shuttles for a specific route ID", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
const result = await repository.getShuttlesByRouteId("r1");
|
||||
expect(result).toEqual(mockShuttles.filter((sh) => sh.routeId === "r1"));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no shuttles for the route ID", async () => {
|
||||
const result = await repository.getShuttlesByRouteId("nonexistent-route");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShuttleById", () => {
|
||||
test("gets a shuttle by ID if it exists", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
const result = await repository.getShuttleById("sh2");
|
||||
expect(result).toEqual(mockShuttles[1]);
|
||||
});
|
||||
|
||||
test("returns null if the shuttle doesn't exist", async () => {
|
||||
const result = await repository.getShuttleById("nonexistent-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrderedStopByRouteAndStopId", () => {
|
||||
test("gets an ordered stop by route ID and stop ID", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
for (const orderedStop of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(orderedStop);
|
||||
}
|
||||
|
||||
const mockOrderedStop = mockOrderedStops[0];
|
||||
const { routeId, stopId } = mockOrderedStop;
|
||||
|
||||
const result = await repository.getOrderedStopByRouteAndStopId(routeId, stopId);
|
||||
expect(result).toEqual(mockOrderedStop);
|
||||
});
|
||||
|
||||
test("returns null if no ordered stop matches the given route ID and stop ID", async () => {
|
||||
const result = await repository.getOrderedStopByRouteAndStopId("nonexistent-route", "nonexistent-stop");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrderedStopsByStopId", () => {
|
||||
test("gets all ordered stops for a specific stop ID", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
for (const orderedStop of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(orderedStop);
|
||||
}
|
||||
|
||||
const result = await repository.getOrderedStopsByStopId("st1");
|
||||
const expected = mockOrderedStops.filter((os) => os.stopId === "st1");
|
||||
expect(result).toHaveLength(expected.length);
|
||||
expect(result).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ordered stops for the stop ID", async () => {
|
||||
const result = await repository.getOrderedStopsByStopId("nonexistent-stop");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrderedStopsByRouteId", () => {
|
||||
test("gets all ordered stops for a specific route ID", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
for (const orderedStop of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(orderedStop);
|
||||
}
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId("r1");
|
||||
const expected = mockOrderedStops.filter((os) => os.routeId === "r1");
|
||||
expect(result).toHaveLength(expected.length);
|
||||
expect(result).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ordered stops for the route ID", async () => {
|
||||
const result = await repository.getOrderedStopsByRouteId("nonexistent-route");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateRoute", () => {
|
||||
test("adds a new route if nonexistent", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
const newRoute = mockRoutes[0];
|
||||
|
||||
await repository.addOrUpdateRoute(newRoute);
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([newRoute]);
|
||||
});
|
||||
|
||||
test("updates an existing route if it exists", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
const existingRoute = mockRoutes[0];
|
||||
const updatedRoute = structuredClone(existingRoute);
|
||||
updatedRoute.name = "Updated Route";
|
||||
|
||||
await repository.addOrUpdateRoute(existingRoute);
|
||||
await repository.addOrUpdateRoute(updatedRoute);
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([updatedRoute]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateShuttle", () => {
|
||||
test("adds a new shuttle if nonexistent", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
const newShuttle = mockShuttles[0];
|
||||
|
||||
await repository.addOrUpdateShuttle(newShuttle);
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([newShuttle]);
|
||||
});
|
||||
|
||||
test("updates an existing shuttle if it exists", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
const existingShuttle = mockShuttles[0];
|
||||
const updatedShuttle = structuredClone(existingShuttle);
|
||||
updatedShuttle.name = "Updated Shuttle";
|
||||
|
||||
await repository.addOrUpdateShuttle(existingShuttle);
|
||||
await repository.addOrUpdateShuttle(updatedShuttle);
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([updatedShuttle]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateStop", () => {
|
||||
test("adds a new stop if nonexistent", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
const newStop = mockStops[0];
|
||||
|
||||
await repository.addOrUpdateStop(newStop);
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([newStop]);
|
||||
});
|
||||
|
||||
test("updates an existing stop if it exists", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
const existingStop = mockStops[0];
|
||||
const updatedStop = structuredClone(existingStop);
|
||||
updatedStop.name = "Updated Stop";
|
||||
|
||||
await repository.addOrUpdateStop(existingStop);
|
||||
await repository.addOrUpdateStop(updatedStop);
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([updatedStop]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateOrderedStop", () => {
|
||||
test("adds a new ordered stop if nonexistent", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
const newOrderedStop = mockOrderedStops[0];
|
||||
|
||||
await repository.addOrUpdateOrderedStop(newOrderedStop);
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId(newOrderedStop.routeId);
|
||||
expect(result).toEqual([newOrderedStop]);
|
||||
});
|
||||
|
||||
test("updates an existing ordered stop if it exists", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
const existingOrderedStop = mockOrderedStops[0];
|
||||
const updatedOrderedStop = structuredClone(existingOrderedStop);
|
||||
updatedOrderedStop.position = 5;
|
||||
|
||||
await repository.addOrUpdateOrderedStop(existingOrderedStop);
|
||||
await repository.addOrUpdateOrderedStop(updatedOrderedStop);
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId(existingOrderedStop.routeId);
|
||||
expect(result).toEqual([updatedOrderedStop]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("removeRouteIfExists", () => {
|
||||
test("removes route given ID", async () => {
|
||||
const systemId = "1";
|
||||
const mockRoutes = generateMockRoutes();
|
||||
await Promise.all(mockRoutes.map(async (route) => {
|
||||
route.systemId = systemId;
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}));
|
||||
|
||||
const routeToRemove = mockRoutes[0];
|
||||
await repository.removeRouteIfExists(routeToRemove.id);
|
||||
|
||||
const remainingRoutes = await repository.getRoutes();
|
||||
expect(remainingRoutes).toHaveLength(mockRoutes.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if route doesn't exist", async () => {
|
||||
const systemId = "1";
|
||||
const mockRoutes = generateMockRoutes();
|
||||
await Promise.all(mockRoutes.map(async (route) => {
|
||||
route.systemId = systemId;
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}));
|
||||
|
||||
await repository.removeRouteIfExists("nonexistent-id");
|
||||
|
||||
const remainingRoutes = await repository.getRoutes();
|
||||
expect(remainingRoutes).toHaveLength(mockRoutes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeShuttleIfExists", () => {
|
||||
test("removes shuttle given ID", async () => {
|
||||
const systemId = "1";
|
||||
const mockShuttles = generateMockShuttles();
|
||||
await Promise.all(mockShuttles.map(async (shuttle) => {
|
||||
shuttle.systemId = systemId;
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}));
|
||||
|
||||
const shuttleToRemove = mockShuttles[0];
|
||||
await repository.removeShuttleIfExists(shuttleToRemove.id);
|
||||
|
||||
const remainingShuttles = await repository.getShuttles();
|
||||
expect(remainingShuttles).toHaveLength(mockShuttles.length - 1);
|
||||
});
|
||||
|
||||
test("removes shuttle last stop if exists", async () => {
|
||||
const { route, stop1, systemId } = await setupRouteAndOrderedStops();
|
||||
const mockShuttle = generateMockShuttles()[0];
|
||||
mockShuttle.routeId = route.id;
|
||||
mockShuttle.coordinates = stop1.coordinates;
|
||||
mockShuttle.systemId = systemId;
|
||||
|
||||
await repository.addOrUpdateShuttle(mockShuttle);
|
||||
await repository.removeShuttleIfExists(mockShuttle.id);
|
||||
|
||||
const lastStopData = await repository.getShuttleLastStopArrival(mockShuttle.id);
|
||||
expect(lastStopData).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does nothing if shuttle doesn't exist", async () => {
|
||||
const systemId = "1";
|
||||
const mockShuttles = generateMockShuttles();
|
||||
await Promise.all(mockShuttles.map(async (shuttle) => {
|
||||
shuttle.systemId = systemId;
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}));
|
||||
|
||||
await repository.removeShuttleIfExists("nonexistent-id");
|
||||
|
||||
const remainingShuttles = await repository.getShuttles();
|
||||
expect(remainingShuttles).toHaveLength(mockShuttles.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeStopIfExists", () => {
|
||||
test("removes stop given ID", async () => {
|
||||
const systemId = "1";
|
||||
const mockStops = generateMockStops();
|
||||
await Promise.all(mockStops.map(async (stop) => {
|
||||
stop.systemId = systemId;
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}));
|
||||
|
||||
const stopToRemove = mockStops[0];
|
||||
await repository.removeStopIfExists(stopToRemove.id);
|
||||
|
||||
const remainingStops = await repository.getStops();
|
||||
expect(remainingStops).toHaveLength(mockStops.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if stop doesn't exist", async () => {
|
||||
const systemId = "1";
|
||||
const mockStops = generateMockStops();
|
||||
await Promise.all(mockStops.map(async (stop) => {
|
||||
stop.systemId = systemId;
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}));
|
||||
|
||||
await repository.removeStopIfExists("nonexistent-id");
|
||||
|
||||
const remainingStops = await repository.getStops();
|
||||
expect(remainingStops).toHaveLength(mockStops.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeOrderedStopIfExists", () => {
|
||||
test("removes ordered stop given stop ID and route ID", async () => {
|
||||
let mockOrderedStops = generateMockOrderedStops();
|
||||
const routeId = mockOrderedStops[0].routeId;
|
||||
mockOrderedStops = mockOrderedStops.filter((orderedStop) => orderedStop.routeId === routeId);
|
||||
await Promise.all(mockOrderedStops.map(async (stop) => {
|
||||
stop.routeId = routeId;
|
||||
await repository.addOrUpdateOrderedStop(stop);
|
||||
}));
|
||||
|
||||
const orderedStopToRemove = mockOrderedStops[0];
|
||||
await repository.removeOrderedStopIfExists(orderedStopToRemove.stopId, orderedStopToRemove.routeId);
|
||||
|
||||
const remainingOrderedStops = await repository.getOrderedStopsByRouteId(routeId);
|
||||
expect(remainingOrderedStops).toHaveLength(mockOrderedStops.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if ordered stop doesn't exist", async () => {
|
||||
let mockOrderedStops = generateMockOrderedStops();
|
||||
const routeId = mockOrderedStops[0].routeId;
|
||||
mockOrderedStops = mockOrderedStops.filter((orderedStop) => orderedStop.routeId === routeId);
|
||||
await Promise.all(mockOrderedStops.map(async (stop) => {
|
||||
stop.routeId = routeId;
|
||||
await repository.addOrUpdateOrderedStop(stop);
|
||||
}));
|
||||
|
||||
await repository.removeOrderedStopIfExists("nonexistent-stop-id", "nonexistent-route-id");
|
||||
|
||||
const remainingOrderedStops = await repository.getOrderedStopsByRouteId(routeId);
|
||||
expect(remainingOrderedStops).toHaveLength(mockOrderedStops.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearShuttleData", () => {
|
||||
test("clears all shuttles from the repository", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
await repository.clearShuttleData();
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("also clears last stop data", async () => {
|
||||
const { route, stop1, stop2, systemId } = await setupRouteAndOrderedStops();
|
||||
|
||||
const mockShuttles = generateMockShuttles();
|
||||
mockShuttles[0].coordinates = stop1.coordinates;
|
||||
mockShuttles[1].coordinates = stop2.coordinates;
|
||||
|
||||
for (const shuttle of mockShuttles) {
|
||||
shuttle.routeId = route.id;
|
||||
shuttle.systemId = systemId;
|
||||
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
await repository.clearShuttleData();
|
||||
|
||||
for (const shuttle of mockShuttles) {
|
||||
const result = await repository.getShuttleLastStopArrival(shuttle.id);
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("clearOrderedStopData", () => {
|
||||
test("clears all ordered stops from the repository", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
for (const system of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(system);
|
||||
}
|
||||
|
||||
await repository.clearOrderedStopData();
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId("route1");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearRouteData", () => {
|
||||
test("clears all routes from the repository", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
for (const route of mockRoutes) {
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}
|
||||
|
||||
await repository.clearRouteData();
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearStopData", () => {
|
||||
test("clears all stops from the repository", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
for (const stop of mockStops) {
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}
|
||||
|
||||
await repository.clearStopData();
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function for setting up routes and ordered stops for shuttle tracking tests
|
||||
async function setupRouteAndOrderedStops() {
|
||||
return await setupRouteAndOrderedStopsForShuttleRepository(repository);
|
||||
}
|
||||
|
||||
describe("addOrUpdateShuttle with shuttle tracking", () => {
|
||||
test("updates the shuttle's last stop arrival if shuttle is at a stop", async () => {
|
||||
const { stop2, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
const lastStop = await repository.getShuttleLastStopArrival(shuttle.id);
|
||||
expect(lastStop?.stopId).toEqual(stop2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArrivedStopIfExists", () => {
|
||||
test("gets any stop that the shuttle is currently at, if the shuttle has not had a last stop", async () => {
|
||||
const { sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
const result = await repository.getArrivedStopIfNextStop(shuttle, false);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe("st2");
|
||||
expect(result?.name).toBe("Stop 2");
|
||||
});
|
||||
|
||||
test("returns undefined if shuttle is not currently at a stop", async () => {
|
||||
const { sampleShuttleNotInRepository } = await setupRouteAndOrderedStops();
|
||||
const shuttle = { ...sampleShuttleNotInRepository, coordinates: { latitude: 12.5, longitude: 22.5 } }; // Not at any stop
|
||||
|
||||
const result = await repository.getArrivedStopIfNextStop(shuttle, false);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("only gets the shuttle's next stop if shuttle has previously arrived at a stop", async () => {
|
||||
const { sampleShuttleNotInRepository: shuttle, stop1, stop2 } = await setupRouteAndOrderedStops();
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
|
||||
let result = await repository.getArrivedStopIfNextStop(shuttle, false);
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
result = await repository.getArrivedStopIfNextStop(shuttle, false);
|
||||
expect(result).not.toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns the shuttle's currently arrived stop if flag passed", async () => {
|
||||
const { sampleShuttleNotInRepository: shuttle, stop1 } = await setupRouteAndOrderedStops();
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
|
||||
const result = await repository.getArrivedStopIfNextStop(shuttle, true);
|
||||
expect(result?.id === stop1.id)
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("getShuttleLastStopArrival", () => {
|
||||
test("gets the shuttle's last stop if existing in the data", async () => {
|
||||
const { stop1, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
const stopArrivalTime = new Date("2024-01-15T10:30:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, stopArrivalTime.getTime());
|
||||
|
||||
const result = await repository.getShuttleLastStopArrival(shuttle.id);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.stopId).toBe(stop1.id);
|
||||
expect(result?.timestamp.getTime()).toBe(stopArrivalTime.getTime());
|
||||
});
|
||||
|
||||
test("returns undefined if the data has never been initialized", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
const shuttle = mockShuttles[0];
|
||||
|
||||
const result = await repository.getShuttleLastStopArrival(shuttle.id);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns the most recent stop arrival when updated multiple times", async () => {
|
||||
const { stop1, stop2, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
const firstArrivalTime = new Date("2024-01-15T10:30:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime());
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
const secondArrivalTime = new Date("2024-01-15T10:35:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime());
|
||||
|
||||
const result = await repository.getShuttleLastStopArrival(shuttle.id);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.stopId).toBe(stop2.id);
|
||||
expect(result?.timestamp.getTime()).toBe(secondArrivalTime.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("SHUTTLE_UPDATED event", () => {
|
||||
test("emits SHUTTLE_UPDATED event when shuttles are added or updated", async () => {
|
||||
const mockListener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener);
|
||||
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
expect(mockListener).toHaveBeenCalledTimes(mockShuttles.length);
|
||||
expect(mockListener).toHaveBeenCalledWith(mockShuttles[0]);
|
||||
expect(mockListener).toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]);
|
||||
});
|
||||
|
||||
test("does not notify listener after it has been removed", async () => {
|
||||
const mockListener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener);
|
||||
|
||||
const mockShuttles = generateMockShuttles();
|
||||
|
||||
repository.off(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener);
|
||||
await repository.addOrUpdateShuttle(mockShuttles[0]);
|
||||
expect(mockListener).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("stops notifying specific listener after removal but continues for others", async () => {
|
||||
const mockListener1 = jest.fn();
|
||||
const mockListener2 = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener1);
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener2);
|
||||
|
||||
const mockShuttles = generateMockShuttles();
|
||||
await repository.addOrUpdateShuttle(mockShuttles[0]);
|
||||
|
||||
repository.off(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener1);
|
||||
|
||||
await repository.addOrUpdateShuttle(mockShuttles[mockShuttles.length - 1]);
|
||||
|
||||
expect(mockListener1).toHaveBeenCalledTimes(1);
|
||||
expect(mockListener1).toHaveBeenCalledWith(mockShuttles[0]);
|
||||
expect(mockListener1).not.toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]);
|
||||
|
||||
expect(mockListener2).toHaveBeenCalledTimes(2);
|
||||
expect(mockListener2).toHaveBeenCalledWith(mockShuttles[0]);
|
||||
expect(mockListener2).toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SHUTTLE_REMOVED event", () => {
|
||||
test("emits SHUTTLE_REMOVED event when a shuttle is removed", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
const shuttleToRemove = mockShuttles[0];
|
||||
const listener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_REMOVED, listener);
|
||||
|
||||
await repository.addOrUpdateShuttle(shuttleToRemove);
|
||||
await repository.removeShuttleIfExists(shuttleToRemove.id);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(shuttleToRemove);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SHUTTLE_WILL_ARRIVE_AT_STOP event", () => {
|
||||
test("emits SHUTTLE_WILL_ARRIVE_AT_STOP event before shuttle arrives at a stop", async () => {
|
||||
const { stop1, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
const listener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener);
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
const arrivalTime = new Date("2024-01-15T10:30:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, arrivalTime.getTime());
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const emittedPayload = listener.mock.calls[0][0] as any;
|
||||
expect(emittedPayload.willArriveAt).toEqual({
|
||||
shuttleId: shuttle.id,
|
||||
stopId: stop1.id,
|
||||
timestamp: arrivalTime,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not emit event when shuttle is not at a stop", async () => {
|
||||
const { sampleShuttleNotInRepository } = await setupRouteAndOrderedStops();
|
||||
|
||||
const listener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener);
|
||||
|
||||
const shuttle = { ...sampleShuttleNotInRepository, coordinates: { latitude: 12.5, longitude: 22.5 } }; // Not at any stop
|
||||
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("emits multiple events as shuttle visits multiple stops", async () => {
|
||||
const { stop1, stop2, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
const listener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener);
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
const firstArrivalTime = new Date("2024-01-15T10:30:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime());
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
const secondArrivalTime = new Date("2024-01-15T10:35:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime());
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
const firstPayload = listener.mock.calls[0][0] as any;
|
||||
expect(firstPayload.willArriveAt).toEqual({
|
||||
shuttleId: shuttle.id,
|
||||
stopId: stop1.id,
|
||||
timestamp: firstArrivalTime,
|
||||
});
|
||||
|
||||
const secondPayload = listener.mock.calls[1][0] as any;
|
||||
expect(secondPayload.willArriveAt).toEqual({
|
||||
shuttleId: shuttle.id,
|
||||
stopId: stop2.id,
|
||||
timestamp: secondArrivalTime,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SHUTTLE_WILL_LEAVE_STOP event", () => {
|
||||
test("emits SHUTTLE_WILL_LEAVE_STOP event when shuttle leaves a stop", async () => {
|
||||
const { stop1, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
const listener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, listener);
|
||||
|
||||
// Simulate arrival at stop 1
|
||||
const arrivalTime = new Date("2024-01-15T10:30:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, arrivalTime.getTime());
|
||||
|
||||
// Test that it actually emits the event correctly and not right after the shuttle arrives
|
||||
await repository.addOrUpdateShuttle(shuttle, arrivalTime.getTime());
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; // Not at any stop
|
||||
|
||||
// Simulate leaving stop 1
|
||||
const leaveTime = new Date("2024-01-15T10:32:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, leaveTime.getTime());
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const emittedPayload = listener.mock.calls[0][0] as any;
|
||||
expect(emittedPayload.stopArrivalThatShuttleIsLeaving).toEqual({
|
||||
shuttleId: shuttle.id,
|
||||
stopId: stop1.id,
|
||||
timestamp: arrivalTime,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not emit event when shuttle was not at a stop", async () => {
|
||||
const { sampleShuttleNotInRepository } = await setupRouteAndOrderedStops();
|
||||
|
||||
const listener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, listener);
|
||||
|
||||
// Start at coordinates not at any stop
|
||||
const shuttle = { ...sampleShuttleNotInRepository, coordinates: { latitude: 12.5, longitude: 22.5 } };
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
|
||||
// Move to different coordinates; still not at any stop
|
||||
shuttle.coordinates = { latitude: 13.0, longitude: 23.0 };
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("emits multiple events as shuttle leaves multiple stops", async () => {
|
||||
const { stop1, stop2, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
const listener = jest.fn();
|
||||
repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, listener);
|
||||
|
||||
// Arrive at stop1
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
const firstArrivalTime = new Date("2024-01-15T10:30:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime());
|
||||
|
||||
// Leave stop1 and arrive at stop2
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
const secondArrivalTime = new Date("2024-01-15T10:35:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime());
|
||||
|
||||
// Leave stop2
|
||||
shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; // Not at any stop
|
||||
const secondLeaveTime = new Date("2024-01-15T10:40:00Z");
|
||||
await repository.addOrUpdateShuttle(shuttle, secondLeaveTime.getTime());
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
const firstPayload = listener.mock.calls[0][0] as any;
|
||||
expect(firstPayload.stopArrivalThatShuttleIsLeaving).toEqual({
|
||||
shuttleId: shuttle.id,
|
||||
stopId: stop1.id,
|
||||
timestamp: firstArrivalTime,
|
||||
});
|
||||
|
||||
const secondPayload = listener.mock.calls[1][0] as any;
|
||||
expect(secondPayload.stopArrivalThatShuttleIsLeaving).toEqual({
|
||||
shuttleId: shuttle.id,
|
||||
stopId: stop2.id,
|
||||
timestamp: secondArrivalTime,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,643 +0,0 @@
|
||||
import { beforeEach, describe, expect, jest, test } from "@jest/globals";
|
||||
import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShuttleRepository";
|
||||
import {
|
||||
generateMockEtas,
|
||||
generateMockOrderedStops,
|
||||
generateMockRoutes,
|
||||
generateMockShuttles,
|
||||
generateMockStops,
|
||||
} from "../../../../testHelpers/mockDataGenerators";
|
||||
|
||||
// For repositories created in the future, reuse core testing
|
||||
// logic from here and differentiate setup (e.g. creating mocks)
|
||||
// Do this by creating a function which takes a ShuttleGetterRepository
|
||||
// or ShuttleGetterSetterRepository instance
|
||||
|
||||
describe("UnoptimizedInMemoryRepository", () => {
|
||||
let repository: UnoptimizedInMemoryShuttleRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new UnoptimizedInMemoryShuttleRepository();
|
||||
});
|
||||
|
||||
describe("getStops", () => {
|
||||
test("gets all stops in the repository", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
for (const stop of mockStops) {
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual(mockStops);
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no stops for the given system ID", async () => {
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStopById", () => {
|
||||
test("gets a stop by ID if it exists", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
const mockStop = mockStops[0];
|
||||
await repository.addOrUpdateStop(mockStop);
|
||||
|
||||
const result = await repository.getStopById("st1");
|
||||
expect(result).toEqual(mockStop);
|
||||
});
|
||||
|
||||
test("returns null if the stop does not exist", async () => {
|
||||
const result = await repository.getStopById("nonexistent-stop");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoutes", () => {
|
||||
test("gets all routes for a specific system ID", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
for (const route of mockRoutes) {
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual(mockRoutes);
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no routes for the system ID", async () => {
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRouteById", () => {
|
||||
test("gets a route by ID if it exists", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
const mockRoute = mockRoutes[0];
|
||||
await repository.addOrUpdateRoute(mockRoute);
|
||||
|
||||
const result = await repository.getRouteById("r1");
|
||||
expect(result).toEqual(mockRoute);
|
||||
});
|
||||
|
||||
test("returns null if the route does not exist", async () => {
|
||||
const result = await repository.getRouteById("nonexistent-route");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
describe("getShuttles", () => {
|
||||
test("gets all shuttles for a specific system ID", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual(mockShuttles);
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no shuttles for the system ID", async () => {
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShuttlesByRouteId", () => {
|
||||
test("gets all shuttles for a specific route ID", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
const result = await repository.getShuttlesByRouteId("r1");
|
||||
expect(result).toEqual(mockShuttles.filter((sh) => sh.routeId === "r1"));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no shuttles for the route ID", async () => {
|
||||
const result = await repository.getShuttlesByRouteId("nonexistent-route");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShuttleById", () => {
|
||||
test("gets a shuttle by ID if it exists", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
const result = await repository.getShuttleById("sh2");
|
||||
expect(result).toEqual(mockShuttles[1]);
|
||||
});
|
||||
|
||||
test("returns null if the shuttle doesn't exist", async () => {
|
||||
const result = await repository.getShuttleById("nonexistent-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEtasForShuttleId", () => {
|
||||
test("gets ETAs for a specific shuttle ID", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
for (const eta of mockEtas) {
|
||||
await repository.addOrUpdateEta(eta);
|
||||
}
|
||||
|
||||
const result = await repository.getEtasForShuttleId("sh1");
|
||||
expect(result).toEqual(mockEtas.filter((eta) => eta.shuttleId === "sh1"));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ETAs for the shuttle ID", async () => {
|
||||
const result = await repository.getEtasForShuttleId("nonexistent-shuttle");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEtasForStopId", () => {
|
||||
test("gets ETAs for a specific stop ID", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
for (const eta of mockEtas) {
|
||||
await repository.addOrUpdateEta(eta);
|
||||
}
|
||||
|
||||
const result = await repository.getEtasForStopId("st1");
|
||||
expect(result).toEqual(mockEtas.filter((eta) => eta.stopId === "st1"));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ETAs for the stop ID", async () => {
|
||||
const result = await repository.getEtasForStopId("nonexistent-stop");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEtaForShuttleAndStopId", () => {
|
||||
test("gets a single ETA for a specific shuttle and stop ID", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
const mockEta = mockEtas[0];
|
||||
await repository.addOrUpdateEta(mockEta);
|
||||
|
||||
const result = await repository.getEtaForShuttleAndStopId("sh1", "st1");
|
||||
expect(result).toEqual(mockEta);
|
||||
});
|
||||
|
||||
test("returns null if no ETA matches the shuttle and stop ID", async () => {
|
||||
const result = await repository.getEtaForShuttleAndStopId("nonexistent-shuttle", "nonexistent-stop");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeToEtaChanges", () => {
|
||||
test("notifies listeners if etas have been added or changed", async () => {
|
||||
const mockCallback = jest.fn(); // Jest mock function to simulate a listener
|
||||
repository.subscribeToEtaUpdates(mockCallback);
|
||||
|
||||
const mockEtas = generateMockEtas();
|
||||
for (const eta of mockEtas) {
|
||||
await repository.addOrUpdateEta(eta); // Trigger changes in ETAs
|
||||
}
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(mockEtas.length);
|
||||
expect(mockCallback).toHaveBeenCalledWith(mockEtas[0]); // First notification
|
||||
expect(mockCallback).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribeFromEtaChanges", () => {
|
||||
test("stops notifying listeners after etas have stopped changing", async () => {
|
||||
const mockCallback = jest.fn(); // Jest mock function to simulate a listener
|
||||
repository.subscribeToEtaUpdates(mockCallback);
|
||||
|
||||
const mockEtas = generateMockEtas();
|
||||
await repository.addOrUpdateEta(mockEtas[0]);
|
||||
|
||||
repository.unsubscribeFromEtaUpdates(mockCallback);
|
||||
|
||||
await repository.addOrUpdateEta(mockEtas[mockEtas.length - 1]);
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallback).toHaveBeenCalledWith(mockEtas[0]); // First notification
|
||||
expect(mockCallback).not.toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification
|
||||
});
|
||||
|
||||
test("does nothing if the listener doesn't exist", async () => {
|
||||
const mockCallback = jest.fn();
|
||||
repository.subscribeToEtaUpdates(mockCallback);
|
||||
|
||||
const mockEtas = generateMockEtas();
|
||||
|
||||
repository.unsubscribeFromEtaUpdates(() => {});
|
||||
await repository.addOrUpdateEta(mockEtas[0]);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrderedStopByRouteAndStopId", () => {
|
||||
test("gets an ordered stop by route ID and stop ID", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
for (const orderedStop of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(orderedStop);
|
||||
}
|
||||
|
||||
const mockOrderedStop = mockOrderedStops[0];
|
||||
const { routeId, stopId } = mockOrderedStop;
|
||||
|
||||
const result = await repository.getOrderedStopByRouteAndStopId(routeId, stopId);
|
||||
expect(result).toEqual(mockOrderedStop);
|
||||
});
|
||||
|
||||
test("returns null if no ordered stop matches the given route ID and stop ID", async () => {
|
||||
const result = await repository.getOrderedStopByRouteAndStopId("nonexistent-route", "nonexistent-stop");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrderedStopsByStopId", () => {
|
||||
test("gets all ordered stops for a specific stop ID", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
for (const orderedStop of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(orderedStop);
|
||||
}
|
||||
|
||||
const result = await repository.getOrderedStopsByStopId("st1");
|
||||
expect(result).toEqual(mockOrderedStops.filter((os) => os.stopId === "st1"));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ordered stops for the stop ID", async () => {
|
||||
const result = await repository.getOrderedStopsByStopId("nonexistent-stop");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrderedStopsByRouteId", () => {
|
||||
test("gets all ordered stops for a specific route ID", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
for (const orderedStop of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(orderedStop);
|
||||
}
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId("r1");
|
||||
expect(result).toEqual(mockOrderedStops.filter((os) => os.routeId === "r1"));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ordered stops for the route ID", async () => {
|
||||
const result = await repository.getOrderedStopsByRouteId("nonexistent-route");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateRoute", () => {
|
||||
test("adds a new route if nonexistent", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
const newRoute = mockRoutes[0];
|
||||
|
||||
await repository.addOrUpdateRoute(newRoute);
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([newRoute]);
|
||||
});
|
||||
|
||||
test("updates an existing route if it exists", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
const existingRoute = mockRoutes[0];
|
||||
const updatedRoute = structuredClone(existingRoute);
|
||||
updatedRoute.name = "Updated Route";
|
||||
|
||||
await repository.addOrUpdateRoute(existingRoute);
|
||||
await repository.addOrUpdateRoute(updatedRoute);
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([updatedRoute]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateShuttle", () => {
|
||||
test("adds a new shuttle if nonexistent", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
const newShuttle = mockShuttles[0];
|
||||
|
||||
await repository.addOrUpdateShuttle(newShuttle);
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([newShuttle]);
|
||||
});
|
||||
|
||||
test("updates an existing shuttle if it exists", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
const existingShuttle = mockShuttles[0];
|
||||
const updatedShuttle = structuredClone(existingShuttle);
|
||||
updatedShuttle.name = "Updated Shuttle";
|
||||
|
||||
await repository.addOrUpdateShuttle(existingShuttle);
|
||||
await repository.addOrUpdateShuttle(updatedShuttle);
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([updatedShuttle]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateStop", () => {
|
||||
test("adds a new stop if nonexistent", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
const newStop = mockStops[0];
|
||||
|
||||
await repository.addOrUpdateStop(newStop);
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([newStop]);
|
||||
});
|
||||
|
||||
test("updates an existing stop if it exists", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
const existingStop = mockStops[0];
|
||||
const updatedStop = structuredClone(existingStop);
|
||||
updatedStop.name = "Updated Stop";
|
||||
|
||||
await repository.addOrUpdateStop(existingStop);
|
||||
await repository.addOrUpdateStop(updatedStop);
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([updatedStop]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateOrderedStop", () => {
|
||||
test("adds a new ordered stop if nonexistent", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
const newOrderedStop = mockOrderedStops[0];
|
||||
|
||||
await repository.addOrUpdateOrderedStop(newOrderedStop);
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId(newOrderedStop.routeId);
|
||||
expect(result).toEqual([newOrderedStop]);
|
||||
});
|
||||
|
||||
test("updates an existing ordered stop if it exists", async () => {
|
||||
const mockOrderedStops = generateMockOrderedStops();
|
||||
const existingOrderedStop = mockOrderedStops[0];
|
||||
const updatedOrderedStop = structuredClone(existingOrderedStop);
|
||||
updatedOrderedStop.position = 5;
|
||||
|
||||
await repository.addOrUpdateOrderedStop(existingOrderedStop);
|
||||
await repository.addOrUpdateOrderedStop(updatedOrderedStop);
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId(existingOrderedStop.routeId);
|
||||
expect(result).toEqual([updatedOrderedStop]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrUpdateEta", () => {
|
||||
test("adds a new ETA if nonexistent", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
const newEta = mockEtas[0];
|
||||
|
||||
await repository.addOrUpdateEta(newEta);
|
||||
|
||||
const result = await repository.getEtasForShuttleId(newEta.shuttleId);
|
||||
expect(result).toEqual([newEta]);
|
||||
});
|
||||
|
||||
test("updates an existing ETA if it exists", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
const existingEta = mockEtas[0];
|
||||
const updatedEta = structuredClone(existingEta);
|
||||
updatedEta.secondsRemaining = existingEta.secondsRemaining + 60;
|
||||
|
||||
await repository.addOrUpdateEta(existingEta);
|
||||
await repository.addOrUpdateEta(updatedEta);
|
||||
|
||||
const result = await repository.getEtasForShuttleId(existingEta.shuttleId);
|
||||
expect(result).toEqual([updatedEta]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeRouteIfExists", () => {
|
||||
test("removes route given ID", async () => {
|
||||
const systemId = "1";
|
||||
const mockRoutes = generateMockRoutes();
|
||||
await Promise.all(mockRoutes.map(async (route) => {
|
||||
route.systemId = systemId;
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}));
|
||||
|
||||
const routeToRemove = mockRoutes[0];
|
||||
await repository.removeRouteIfExists(routeToRemove.id);
|
||||
|
||||
const remainingRoutes = await repository.getRoutes();
|
||||
expect(remainingRoutes).toHaveLength(mockRoutes.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if route doesn't exist", async () => {
|
||||
const systemId = "1";
|
||||
const mockRoutes = generateMockRoutes();
|
||||
await Promise.all(mockRoutes.map(async (route) => {
|
||||
route.systemId = systemId;
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}));
|
||||
|
||||
await repository.removeRouteIfExists("nonexistent-id");
|
||||
|
||||
const remainingRoutes = await repository.getRoutes();
|
||||
expect(remainingRoutes).toHaveLength(mockRoutes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeShuttleIfExists", () => {
|
||||
test("removes shuttle given ID", async () => {
|
||||
const systemId = "1";
|
||||
const mockShuttles = generateMockShuttles();
|
||||
await Promise.all(mockShuttles.map(async (shuttle) => {
|
||||
shuttle.systemId = systemId;
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}));
|
||||
|
||||
const shuttleToRemove = mockShuttles[0];
|
||||
await repository.removeShuttleIfExists(shuttleToRemove.id);
|
||||
|
||||
const remainingShuttles = await repository.getShuttles();
|
||||
expect(remainingShuttles).toHaveLength(mockShuttles.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if shuttle doesn't exist", async () => {
|
||||
const systemId = "1";
|
||||
const mockShuttles = generateMockShuttles();
|
||||
await Promise.all(mockShuttles.map(async (shuttle) => {
|
||||
shuttle.systemId = systemId;
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}));
|
||||
|
||||
await repository.removeShuttleIfExists("nonexistent-id");
|
||||
|
||||
const remainingShuttles = await repository.getShuttles();
|
||||
expect(remainingShuttles).toHaveLength(mockShuttles.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeStopIfExists", () => {
|
||||
test("removes stop given ID", async () => {
|
||||
const systemId = "1";
|
||||
const mockStops = generateMockStops();
|
||||
await Promise.all(mockStops.map(async (stop) => {
|
||||
stop.systemId = systemId;
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}));
|
||||
|
||||
const stopToRemove = mockStops[0];
|
||||
await repository.removeStopIfExists(stopToRemove.id);
|
||||
|
||||
const remainingStops = await repository.getStops();
|
||||
expect(remainingStops).toHaveLength(mockStops.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if stop doesn't exist", async () => {
|
||||
const systemId = "1";
|
||||
const mockStops = generateMockStops();
|
||||
await Promise.all(mockStops.map(async (stop) => {
|
||||
stop.systemId = systemId;
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}));
|
||||
|
||||
await repository.removeStopIfExists("nonexistent-id");
|
||||
|
||||
const remainingStops = await repository.getStops();
|
||||
expect(remainingStops).toHaveLength(mockStops.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeOrderedStopIfExists", () => {
|
||||
test("removes ordered stop given stop ID and route ID", async () => {
|
||||
let mockOrderedStops = generateMockOrderedStops();
|
||||
const routeId = mockOrderedStops[0].routeId;
|
||||
mockOrderedStops = mockOrderedStops.filter((orderedStop) => orderedStop.routeId === routeId);
|
||||
await Promise.all(mockOrderedStops.map(async (stop) => {
|
||||
stop.routeId = routeId;
|
||||
await repository.addOrUpdateOrderedStop(stop);
|
||||
}));
|
||||
|
||||
const orderedStopToRemove = mockOrderedStops[0];
|
||||
await repository.removeOrderedStopIfExists(orderedStopToRemove.stopId, orderedStopToRemove.routeId);
|
||||
|
||||
const remainingOrderedStops = await repository.getOrderedStopsByRouteId(routeId);
|
||||
expect(remainingOrderedStops).toHaveLength(mockOrderedStops.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if ordered stop doesn't exist", async () => {
|
||||
let mockOrderedStops = generateMockOrderedStops();
|
||||
const routeId = mockOrderedStops[0].routeId;
|
||||
mockOrderedStops = mockOrderedStops.filter((orderedStop) => orderedStop.routeId === routeId);
|
||||
await Promise.all(mockOrderedStops.map(async (stop) => {
|
||||
stop.routeId = routeId;
|
||||
await repository.addOrUpdateOrderedStop(stop);
|
||||
}));
|
||||
|
||||
await repository.removeOrderedStopIfExists("nonexistent-stop-id", "nonexistent-route-id");
|
||||
|
||||
const remainingOrderedStops = await repository.getOrderedStopsByRouteId(routeId);
|
||||
expect(remainingOrderedStops).toHaveLength(mockOrderedStops.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeEtaIfExists", () => {
|
||||
test("removes eta given shuttle ID and stop ID", async () => {
|
||||
let mockEtas = generateMockEtas();
|
||||
const stopId = mockEtas[0].stopId;
|
||||
mockEtas = mockEtas.filter((eta) => eta.stopId === stopId);
|
||||
|
||||
await Promise.all(mockEtas.map(async (eta) => {
|
||||
eta.stopId = stopId;
|
||||
await repository.addOrUpdateEta(eta);
|
||||
}));
|
||||
|
||||
const etaToRemove = mockEtas[0];
|
||||
await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId);
|
||||
|
||||
const remainingEtas = await repository.getEtasForStopId(stopId);
|
||||
expect(remainingEtas).toHaveLength(mockEtas.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if eta doesn't exist", async () => {
|
||||
let mockEtas = generateMockEtas();
|
||||
const stopId = mockEtas[0].stopId;
|
||||
mockEtas = mockEtas.filter((eta) => eta.stopId === stopId);
|
||||
|
||||
await Promise.all(mockEtas.map(async (eta) => {
|
||||
eta.stopId = stopId;
|
||||
await repository.addOrUpdateEta(eta);
|
||||
}));
|
||||
|
||||
await repository.removeEtaIfExists("nonexistent-shuttle-id", "nonexistent-stop-id");
|
||||
|
||||
const remainingEtas = await repository.getEtasForStopId(stopId);
|
||||
expect(remainingEtas).toHaveLength(mockEtas.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearShuttleData", () => {
|
||||
test("clears all shuttles from the repository", async () => {
|
||||
const mockShuttles = generateMockShuttles();
|
||||
for (const shuttle of mockShuttles) {
|
||||
await repository.addOrUpdateShuttle(shuttle);
|
||||
}
|
||||
|
||||
await repository.clearShuttleData();
|
||||
|
||||
const result = await repository.getShuttles();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearEtaData", () => {
|
||||
test("clears all ETAs from the repository", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
for (const eta of mockEtas) {
|
||||
await repository.addOrUpdateEta(eta);
|
||||
}
|
||||
|
||||
await repository.clearEtaData();
|
||||
|
||||
const result = await repository.getEtasForShuttleId("shuttle1");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearOrderedStopData", () => {
|
||||
test("clears all ordered stops from the repository", async () => {
|
||||
const mockOrderedStops = await generateMockOrderedStops();
|
||||
for (const system of mockOrderedStops) {
|
||||
await repository.addOrUpdateOrderedStop(system);
|
||||
}
|
||||
|
||||
await repository.clearOrderedStopData();
|
||||
|
||||
const result = await repository.getOrderedStopsByRouteId("route1");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearRouteData", () => {
|
||||
test("clears all routes from the repository", async () => {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
for (const route of mockRoutes) {
|
||||
await repository.addOrUpdateRoute(route);
|
||||
}
|
||||
|
||||
await repository.clearRouteData();
|
||||
|
||||
const result = await repository.getRoutes();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearStopData", () => {
|
||||
test("clears all stops from the repository", async () => {
|
||||
const mockStops = generateMockStops();
|
||||
for (const stop of mockStops) {
|
||||
await repository.addOrUpdateStop(stop);
|
||||
}
|
||||
|
||||
await repository.clearStopData();
|
||||
|
||||
const result = await repository.getStops();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/repositories/shuttle/eta/BaseInMemoryETARepository.ts
Normal file
76
src/repositories/shuttle/eta/BaseInMemoryETARepository.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { IEta } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository";
|
||||
import EventEmitter from "node:events";
|
||||
|
||||
export abstract class BaseInMemoryETARepository extends EventEmitter implements ETAGetterRepository {
|
||||
protected etas: IEta[] = [];
|
||||
|
||||
async getEtasForShuttleId(shuttleId: string): Promise<IEta[]> {
|
||||
return this.etas.filter(eta => eta.shuttleId === shuttleId);
|
||||
}
|
||||
|
||||
async getEtasForStopId(stopId: string): Promise<IEta[]> {
|
||||
return this.etas.filter(eta => eta.stopId === stopId);
|
||||
}
|
||||
|
||||
async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise<IEta | null> {
|
||||
const eta = this.etas.find(eta => eta.stopId === stopId && eta.shuttleId === shuttleId);
|
||||
return eta ?? null;
|
||||
}
|
||||
|
||||
// Protected setter for internal use
|
||||
protected async addOrUpdateEta(eta: IEta): Promise<void> {
|
||||
const index = this.etas.findIndex((e) => e.stopId === eta.stopId && e.shuttleId === eta.shuttleId);
|
||||
if (index !== -1) {
|
||||
this.etas[index] = eta;
|
||||
} else {
|
||||
this.etas.push(eta);
|
||||
}
|
||||
this.emit(ETARepositoryEvent.ETA_UPDATED, eta);
|
||||
}
|
||||
|
||||
// EventEmitter overrides for type safety
|
||||
override on<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
override once<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
override off<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override off(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
override addListener<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override addListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.addListener(event, listener);
|
||||
}
|
||||
|
||||
override removeListener<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override removeListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.removeListener(event, listener);
|
||||
}
|
||||
|
||||
override removeAllListeners(eventName?: string | symbol): this {
|
||||
return super.removeAllListeners(eventName);
|
||||
}
|
||||
}
|
||||
120
src/repositories/shuttle/eta/BaseRedisETARepository.ts
Normal file
120
src/repositories/shuttle/eta/BaseRedisETARepository.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { IEta } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { BaseRedisRepository } from "../../BaseRedisRepository";
|
||||
import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository";
|
||||
|
||||
export abstract class BaseRedisETARepository extends BaseRedisRepository implements ETAGetterRepository {
|
||||
private static readonly ETA_KEY_PREFIX = 'shuttle:eta:';
|
||||
|
||||
// Helper methods
|
||||
protected createEtaKey = (shuttleId: string, stopId: string) =>
|
||||
`${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:${stopId}`;
|
||||
|
||||
createRedisHashFromEta = (eta: IEta): Record<string, string> => ({
|
||||
secondsRemaining: eta.secondsRemaining.toString(),
|
||||
shuttleId: eta.shuttleId,
|
||||
stopId: eta.stopId,
|
||||
systemId: eta.systemId,
|
||||
updatedTime: eta.updatedTime.toISOString(),
|
||||
});
|
||||
|
||||
createEtaFromRedisData = (data: Record<string, string>): IEta => ({
|
||||
secondsRemaining: parseFloat(data.secondsRemaining),
|
||||
shuttleId: data.shuttleId,
|
||||
stopId: data.stopId,
|
||||
systemId: data.systemId,
|
||||
updatedTime: new Date(data.updatedTime),
|
||||
});
|
||||
|
||||
// Getter implementations
|
||||
async getEtasForShuttleId(shuttleId: string): Promise<IEta[]> {
|
||||
const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:*`);
|
||||
const etas: IEta[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
if (Object.keys(data).length > 0) {
|
||||
etas.push(this.createEtaFromRedisData(data));
|
||||
}
|
||||
}
|
||||
|
||||
return etas;
|
||||
}
|
||||
|
||||
async getEtasForStopId(stopId: string): Promise<IEta[]> {
|
||||
const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}*`);
|
||||
const etas: IEta[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
if (Object.keys(data).length > 0 && data.stopId === stopId) {
|
||||
etas.push(this.createEtaFromRedisData(data));
|
||||
}
|
||||
}
|
||||
|
||||
return etas;
|
||||
}
|
||||
|
||||
async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise<IEta | null> {
|
||||
const key = this.createEtaKey(shuttleId, stopId);
|
||||
const data = await this.redisClient.hGetAll(key);
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createEtaFromRedisData(data);
|
||||
}
|
||||
|
||||
// Protected setter for internal use
|
||||
protected async addOrUpdateEta(eta: IEta): Promise<void> {
|
||||
const key = this.createEtaKey(eta.shuttleId, eta.stopId);
|
||||
const hash = this.createRedisHashFromEta(eta);
|
||||
await this.redisClient.hSet(key, hash);
|
||||
this.emit(ETARepositoryEvent.ETA_UPDATED, eta);
|
||||
}
|
||||
|
||||
// EventEmitter override methods for type safety
|
||||
override on<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
override once<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
override off<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override off(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
|
||||
override addListener<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override addListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.addListener(event, listener);
|
||||
}
|
||||
|
||||
override removeListener<T extends ETARepositoryEventName>(
|
||||
event: T,
|
||||
listener: ETARepositoryEventListener<T>,
|
||||
): this;
|
||||
override removeListener(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.removeListener(event, listener);
|
||||
}
|
||||
|
||||
override removeAllListeners(eventName?: string | symbol): this {
|
||||
return super.removeAllListeners(eventName);
|
||||
}
|
||||
}
|
||||
37
src/repositories/shuttle/eta/ETAGetterRepository.ts
Normal file
37
src/repositories/shuttle/eta/ETAGetterRepository.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { EventEmitter } from "stream";
|
||||
import { IEta } from "../../../entities/ShuttleRepositoryEntities";
|
||||
|
||||
// TODO: Remove these events in ShuttleGetterRepository
|
||||
|
||||
export const ETARepositoryEvent = {
|
||||
ETA_UPDATED: "etaUpdated",
|
||||
ETA_REMOVED: "etaRemoved",
|
||||
ETA_DATA_CLEARED: "etaDataCleared",
|
||||
} as const;
|
||||
|
||||
export type ETARepositoryEventName = typeof ETARepositoryEvent[keyof typeof ETARepositoryEvent];
|
||||
|
||||
export type EtaRemovedEventPayload = IEta;
|
||||
export type EtaDataClearedEventPayload = IEta[];
|
||||
|
||||
export interface ETARepositoryEventPayloads {
|
||||
[ETARepositoryEvent.ETA_UPDATED]: IEta;
|
||||
[ETARepositoryEvent.ETA_REMOVED]: EtaRemovedEventPayload;
|
||||
[ETARepositoryEvent.ETA_DATA_CLEARED]: EtaDataClearedEventPayload;
|
||||
}
|
||||
|
||||
export type ETARepositoryEventListener<T extends ETARepositoryEventName> = (
|
||||
payload: ETARepositoryEventPayloads[T],
|
||||
) => void;
|
||||
|
||||
export interface ETAGetterRepository extends EventEmitter {
|
||||
on<T extends ETARepositoryEventName>(event: T, listener: ETARepositoryEventListener<T>): this;
|
||||
once<T extends ETARepositoryEventName>(event: T, listener: ETARepositoryEventListener<T>): this;
|
||||
off<T extends ETARepositoryEventName>(event: T, listener: ETARepositoryEventListener<T>): this;
|
||||
addListener<T extends ETARepositoryEventName>(event: T, listener: ETARepositoryEventListener<T>): this;
|
||||
removeListener<T extends ETARepositoryEventName>(event: T, listener: ETARepositoryEventListener<T>): this;
|
||||
|
||||
getEtasForShuttleId(shuttleId: string): Promise<IEta[]>;
|
||||
getEtasForStopId(stopId: string): Promise<IEta[]>;
|
||||
getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise<IEta | null>;
|
||||
}
|
||||
11
src/repositories/shuttle/eta/ExternalSourceETARepository.ts
Normal file
11
src/repositories/shuttle/eta/ExternalSourceETARepository.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IEta } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { ETAGetterRepository } from "./ETAGetterRepository";
|
||||
|
||||
export interface ExternalSourceETARepository extends ETAGetterRepository {
|
||||
/**
|
||||
* Add or update an ETA from an external source (e.g., API or test data).
|
||||
*/
|
||||
addOrUpdateEtaFromExternalSource(eta: IEta): Promise<void>;
|
||||
|
||||
removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IEta } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { ExternalSourceETARepository } from "./ExternalSourceETARepository";
|
||||
import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository";
|
||||
import { ETARepositoryEvent } from "./ETAGetterRepository";
|
||||
|
||||
export class InMemoryExternalSourceETARepository extends BaseInMemoryETARepository implements ExternalSourceETARepository {
|
||||
async addOrUpdateEtaFromExternalSource(eta: IEta): Promise<void> {
|
||||
await this.addOrUpdateEta(eta);
|
||||
}
|
||||
|
||||
async removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null> {
|
||||
const index = this.etas.findIndex((e) => e.stopId === stopId && e.shuttleId === shuttleId);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const removedEta = this.etas[index];
|
||||
this.etas.splice(index, 1);
|
||||
this.emit(ETARepositoryEvent.ETA_REMOVED, removedEta);
|
||||
return removedEta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository";
|
||||
import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments, ShuttleWillArriveAtStopPayload, ShuttleWillLeaveStopPayload } from "../ShuttleGetterRepository";
|
||||
import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository";
|
||||
import { IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { ETARepositoryEvent } from "./ETAGetterRepository";
|
||||
|
||||
export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository implements SelfUpdatingETARepository {
|
||||
private referenceTime: Date | null = null;
|
||||
private travelTimeData: Map<string, Array<{ timestamp: number; seconds: number }>> = new Map();
|
||||
|
||||
private isListening = false;
|
||||
|
||||
constructor(
|
||||
readonly shuttleRepository: ShuttleGetterRepository
|
||||
) {
|
||||
super();
|
||||
|
||||
this.setReferenceTime = this.setReferenceTime.bind(this);
|
||||
this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this);
|
||||
this.startListeningForUpdates = this.startListeningForUpdates.bind(this);
|
||||
this.handleShuttleWillArriveAtStop = this.handleShuttleWillArriveAtStop.bind(this);
|
||||
this.handleShuttleUpdate = this.handleShuttleUpdate.bind(this);
|
||||
this.updateCascadingEta = this.updateCascadingEta.bind(this);
|
||||
this.getAverageTravelTimeSecondsWithFallbacks = this.getAverageTravelTimeSecondsWithFallbacks.bind(this);
|
||||
this.removeEtaIfExists = this.removeEtaIfExists.bind(this);
|
||||
this.handleShuttleWillLeaveStop = this.handleShuttleWillLeaveStop.bind(this);
|
||||
}
|
||||
|
||||
setReferenceTime(referenceTime: Date): void {
|
||||
this.referenceTime = referenceTime;
|
||||
}
|
||||
|
||||
async getAverageTravelTimeSeconds(
|
||||
{ routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier,
|
||||
{ from, to }: ShuttleTravelTimeDateFilterArguments
|
||||
): Promise<number | undefined> {
|
||||
const key = `${routeId}:${fromStopId}:${toStopId}`;
|
||||
const dataPoints = this.travelTimeData.get(key);
|
||||
|
||||
if (!dataPoints || dataPoints.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fromTimestamp = from.getTime();
|
||||
const toTimestamp = to.getTime();
|
||||
|
||||
const filteredPoints = dataPoints.filter(
|
||||
(point) => point.timestamp >= fromTimestamp && point.timestamp <= toTimestamp
|
||||
);
|
||||
|
||||
if (filteredPoints.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sum = filteredPoints.reduce((acc, point) => acc + point.seconds, 0);
|
||||
return sum / filteredPoints.length;
|
||||
}
|
||||
|
||||
startListeningForUpdates(): void {
|
||||
if (this.isListening) {
|
||||
console.warn("Already listening to updates; did you call startListeningForUpdates twice?");
|
||||
return;
|
||||
}
|
||||
this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate);
|
||||
this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop);
|
||||
this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, this.handleShuttleWillLeaveStop);
|
||||
this.isListening = true;
|
||||
}
|
||||
stopListeningForUpdates(): void {
|
||||
if (!this.isListening) {
|
||||
return;
|
||||
}
|
||||
this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate);
|
||||
this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop);
|
||||
this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, this.handleShuttleWillLeaveStop);
|
||||
this.isListening = false;
|
||||
}
|
||||
|
||||
private async getAverageTravelTimeSecondsWithFallbacks(
|
||||
identifier: ShuttleTravelTimeDataIdentifier,
|
||||
dateFilters: ShuttleTravelTimeDateFilterArguments[]
|
||||
): Promise<number | undefined> {
|
||||
for (const dateFilter of dateFilters) {
|
||||
const result = await this.getAverageTravelTimeSeconds(identifier, dateFilter);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async handleShuttleUpdate(shuttle: IShuttle): Promise<void> {
|
||||
const isAtStop = await this.shuttleRepository.checkIfShuttleIsAtStop(shuttle.id);
|
||||
const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id);
|
||||
if (!lastStop) return;
|
||||
|
||||
if (isAtStop) {
|
||||
// Update the ETA *to* the stop the shuttle is currently at,
|
||||
// before starting from the current stop as normal.
|
||||
// Account for cases where the shuttle arrived way earlier than
|
||||
// expected based on the calculated ETA.
|
||||
|
||||
await this.addOrUpdateEta({
|
||||
secondsRemaining: 1,
|
||||
shuttleId: shuttle.id,
|
||||
stopId: lastStop.stopId,
|
||||
systemId: shuttle.systemId,
|
||||
updatedTime: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId);
|
||||
|
||||
await this.updateCascadingEta({
|
||||
shuttle,
|
||||
currentStop: lastOrderedStop,
|
||||
originalStopArrival: lastStop,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateCascadingEta({
|
||||
shuttle,
|
||||
currentStop,
|
||||
originalStopArrival,
|
||||
runningTravelTimeSeconds = 0
|
||||
}: {
|
||||
shuttle: IShuttle;
|
||||
currentStop: IOrderedStop | null;
|
||||
originalStopArrival: ShuttleStopArrival;
|
||||
runningTravelTimeSeconds?: number;
|
||||
}) {
|
||||
if (!currentStop) return;
|
||||
const nextStop = currentStop?.nextStop;
|
||||
if (!nextStop) return;
|
||||
// In case the system we have loops around
|
||||
if (nextStop.stopId === originalStopArrival.stopId) return;
|
||||
|
||||
let referenceCurrentTime = new Date();
|
||||
if (this.referenceTime != null) {
|
||||
referenceCurrentTime = this.referenceTime;
|
||||
}
|
||||
|
||||
const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000));
|
||||
const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000));
|
||||
const twoHoursAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000));
|
||||
|
||||
const twoHoursInMs = 120 * 60 * 1000;
|
||||
const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({
|
||||
routeId: shuttle.routeId,
|
||||
fromStopId: currentStop.stopId,
|
||||
toStopId: nextStop.stopId,
|
||||
}, [
|
||||
{
|
||||
from: twoHoursAgo,
|
||||
to: new Date(),
|
||||
},
|
||||
{
|
||||
from: new Date(oneDayAgo.getTime() - (twoHoursInMs)),
|
||||
to: oneDayAgo,
|
||||
},
|
||||
{
|
||||
from: new Date(oneWeekAgo.getTime() - (twoHoursInMs)),
|
||||
to: oneWeekAgo,
|
||||
},
|
||||
]);
|
||||
|
||||
if (travelTimeSeconds == undefined) return;
|
||||
|
||||
const elapsedTimeMs = referenceCurrentTime.getTime() - originalStopArrival.timestamp.getTime();
|
||||
const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000) + runningTravelTimeSeconds;
|
||||
|
||||
await this.addOrUpdateEta({
|
||||
secondsRemaining: predictedTimeSeconds,
|
||||
shuttleId: shuttle.id,
|
||||
stopId: nextStop.stopId,
|
||||
systemId: nextStop.systemId,
|
||||
updatedTime: new Date(),
|
||||
});
|
||||
|
||||
const nextStopWithNextNextStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, nextStop.stopId);
|
||||
await this.updateCascadingEta(
|
||||
{
|
||||
shuttle,
|
||||
currentStop: nextStopWithNextNextStop,
|
||||
originalStopArrival,
|
||||
runningTravelTimeSeconds: runningTravelTimeSeconds + travelTimeSeconds,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private async handleShuttleWillArriveAtStop({
|
||||
lastStopArrival: lastArrival,
|
||||
willArriveAt: currentArrival,
|
||||
}: ShuttleWillArriveAtStopPayload): Promise<void> {
|
||||
if (lastArrival) {
|
||||
// disallow cases where this gets triggered multiple times
|
||||
if (lastArrival.stopId === currentArrival.stopId) return;
|
||||
|
||||
const shuttle = await this.shuttleRepository.getShuttleById(lastArrival.shuttleId);
|
||||
if (!shuttle) return;
|
||||
|
||||
const routeId = shuttle.routeId;
|
||||
const fromStopId = lastArrival.stopId;
|
||||
const toStopId = currentArrival.stopId;
|
||||
|
||||
const travelTimeSeconds = (currentArrival.timestamp.getTime() - lastArrival.timestamp.getTime()) / 1000;
|
||||
await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId }, travelTimeSeconds, currentArrival.timestamp.getTime());
|
||||
}
|
||||
}
|
||||
|
||||
private async handleShuttleWillLeaveStop({
|
||||
stopArrivalThatShuttleIsLeaving,
|
||||
}: ShuttleWillLeaveStopPayload) {
|
||||
await this.removeEtaIfExists(stopArrivalThatShuttleIsLeaving.shuttleId, stopArrivalThatShuttleIsLeaving.stopId);
|
||||
}
|
||||
|
||||
private async addTravelTimeDataPoint(
|
||||
{ routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier,
|
||||
travelTimeSeconds: number,
|
||||
timestamp = Date.now(),
|
||||
): Promise<void> {
|
||||
const key = `${routeId}:${fromStopId}:${toStopId}`;
|
||||
const dataPoints = this.travelTimeData.get(key) || [];
|
||||
dataPoints.push({ timestamp, seconds: travelTimeSeconds });
|
||||
this.travelTimeData.set(key, dataPoints);
|
||||
}
|
||||
|
||||
private async removeEtaIfExists(shuttleId: string, stopId: string) {
|
||||
const index = this.etas.findIndex((e) => e.stopId === stopId && e.shuttleId === shuttleId);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const removedEta = this.etas[index];
|
||||
this.etas.splice(index, 1);
|
||||
this.emit(ETARepositoryEvent.ETA_REMOVED, removedEta);
|
||||
return removedEta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IEta } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { BaseRedisETARepository } from "./BaseRedisETARepository";
|
||||
import { ExternalSourceETARepository } from "./ExternalSourceETARepository";
|
||||
import { ETARepositoryEvent } from "./ETAGetterRepository";
|
||||
|
||||
export class RedisExternalSourceETARepository extends BaseRedisETARepository implements ExternalSourceETARepository {
|
||||
async addOrUpdateEtaFromExternalSource(eta: IEta): Promise<void> {
|
||||
await this.addOrUpdateEta(eta);
|
||||
}
|
||||
|
||||
async removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null> {
|
||||
const existingEta = await this.getEtaForShuttleAndStopId(shuttleId, stopId);
|
||||
if (existingEta === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = this.createEtaKey(shuttleId, stopId);
|
||||
await this.redisClient.del(key);
|
||||
this.emit(ETARepositoryEvent.ETA_REMOVED, existingEta);
|
||||
return existingEta;
|
||||
}
|
||||
}
|
||||
315
src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts
Normal file
315
src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository";
|
||||
import { BaseRedisETARepository } from "./BaseRedisETARepository";
|
||||
import { RedisClientType } from "redis";
|
||||
import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments, ShuttleWillArriveAtStopPayload, ShuttleWillLeaveStopPayload } from "../ShuttleGetterRepository";
|
||||
import { IEta, IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities";
|
||||
import { ETARepositoryEvent } from "./ETAGetterRepository";
|
||||
import createRedisClientForRepository from "../../../helpers/createRedisClientForRepository";
|
||||
|
||||
export class RedisSelfUpdatingETARepository extends BaseRedisETARepository implements SelfUpdatingETARepository {
|
||||
private isListening = false;
|
||||
|
||||
constructor(
|
||||
readonly shuttleRepository: ShuttleGetterRepository,
|
||||
redisClient: RedisClientType = createRedisClientForRepository(),
|
||||
private referenceTime: Date | null = null,
|
||||
) {
|
||||
super(redisClient);
|
||||
|
||||
this.setReferenceTime = this.setReferenceTime.bind(this);
|
||||
this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this);
|
||||
this.startListeningForUpdates = this.startListeningForUpdates.bind(this);
|
||||
this.handleShuttleWillArriveAtStop = this.handleShuttleWillArriveAtStop.bind(this);
|
||||
this.handleShuttleUpdate = this.handleShuttleUpdate.bind(this);
|
||||
this.updateCascadingEta = this.updateCascadingEta.bind(this);
|
||||
this.getAverageTravelTimeSecondsWithFallbacks = this.getAverageTravelTimeSecondsWithFallbacks.bind(this);
|
||||
this.removeEtaIfExists = this.removeEtaIfExists.bind(this);
|
||||
this.handleShuttleWillLeaveStop = this.handleShuttleWillLeaveStop.bind(this);
|
||||
}
|
||||
|
||||
private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => {
|
||||
return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`;
|
||||
}
|
||||
|
||||
setReferenceTime(referenceTime: Date) {
|
||||
this.referenceTime = referenceTime;
|
||||
}
|
||||
|
||||
public async getAverageTravelTimeSeconds(
|
||||
{ routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier,
|
||||
{ from, to }: ShuttleTravelTimeDateFilterArguments
|
||||
): Promise<number | undefined> {
|
||||
const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId);
|
||||
const fromTimestamp = from.getTime();
|
||||
const toTimestamp = to.getTime();
|
||||
const intervalMs = toTimestamp - fromTimestamp + 1;
|
||||
|
||||
try {
|
||||
const aggregationResult = await this.redisClient.sendCommand([
|
||||
'TS.RANGE',
|
||||
timeSeriesKey,
|
||||
fromTimestamp.toString(),
|
||||
toTimestamp.toString(),
|
||||
'AGGREGATION',
|
||||
'AVG',
|
||||
intervalMs.toString()
|
||||
]) as [string, string][];
|
||||
|
||||
if (aggregationResult && aggregationResult.length > 0) {
|
||||
const [, averageValue] = aggregationResult[0];
|
||||
return parseFloat(averageValue);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get average travel time for ${timeSeriesKey}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
startListeningForUpdates(): void {
|
||||
if (this.isListening) {
|
||||
console.warn("Already listening to updates; did you call startListeningForUpdates twice?");
|
||||
return;
|
||||
}
|
||||
this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate);
|
||||
this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop);
|
||||
this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, this.handleShuttleWillLeaveStop);
|
||||
this.isListening = true;
|
||||
}
|
||||
|
||||
stopListeningForUpdates(): void {
|
||||
if (!this.isListening) {
|
||||
return;
|
||||
}
|
||||
this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate);
|
||||
this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop);
|
||||
this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_LEAVE_STOP, this.handleShuttleWillLeaveStop);
|
||||
this.isListening = false;
|
||||
}
|
||||
|
||||
private async getAverageTravelTimeSecondsWithFallbacks(
|
||||
identifier: ShuttleTravelTimeDataIdentifier,
|
||||
dateFilters: ShuttleTravelTimeDateFilterArguments[]
|
||||
): Promise<number | undefined> {
|
||||
for (const dateFilter of dateFilters) {
|
||||
const result = await this.getAverageTravelTimeSeconds(identifier, dateFilter);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async handleShuttleUpdate(shuttle: IShuttle) {
|
||||
const isAtStop = await this.shuttleRepository.checkIfShuttleIsAtStop(shuttle.id);
|
||||
const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id);
|
||||
if (!lastStop) return;
|
||||
|
||||
if (isAtStop) {
|
||||
// Update the ETA *to* the stop the shuttle is currently at,
|
||||
// before starting from the current stop as normal.
|
||||
// Account for cases where the shuttle arrived way earlier than
|
||||
// expected based on the calculated ETA.
|
||||
|
||||
await this.addOrUpdateEta({
|
||||
secondsRemaining: 1,
|
||||
shuttleId: shuttle.id,
|
||||
stopId: lastStop.stopId,
|
||||
systemId: shuttle.systemId,
|
||||
updatedTime: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId);
|
||||
|
||||
await this.updateCascadingEta({
|
||||
shuttle,
|
||||
currentStop: lastOrderedStop,
|
||||
originalStopArrival: lastStop,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateCascadingEta({
|
||||
shuttle,
|
||||
currentStop,
|
||||
originalStopArrival,
|
||||
runningTravelTimeSeconds = 0
|
||||
}: {
|
||||
shuttle: IShuttle;
|
||||
currentStop: IOrderedStop | null;
|
||||
originalStopArrival: ShuttleStopArrival;
|
||||
runningTravelTimeSeconds?: number;
|
||||
}) {
|
||||
if (!currentStop) return;
|
||||
const nextStop = currentStop?.nextStop;
|
||||
if (!nextStop) return;
|
||||
// In case the system we have loops around
|
||||
if (nextStop.stopId === originalStopArrival.stopId) return;
|
||||
|
||||
let referenceCurrentTime = new Date();
|
||||
if (this.referenceTime != null) {
|
||||
referenceCurrentTime = this.referenceTime;
|
||||
}
|
||||
|
||||
const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000));
|
||||
const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000));
|
||||
const twoHoursAgo = new Date(referenceCurrentTime.getTime() - (120 * 60 * 1000));
|
||||
|
||||
const twoHoursInMs = 120 * 60 * 1000;
|
||||
const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({
|
||||
routeId: shuttle.routeId,
|
||||
fromStopId: currentStop.stopId,
|
||||
toStopId: nextStop.stopId,
|
||||
}, [
|
||||
{
|
||||
from: twoHoursAgo,
|
||||
to: new Date(),
|
||||
},
|
||||
{
|
||||
from: new Date(oneDayAgo.getTime() - (twoHoursInMs)),
|
||||
to: oneDayAgo,
|
||||
},
|
||||
{
|
||||
from: new Date(oneWeekAgo.getTime() - (twoHoursInMs)),
|
||||
to: oneWeekAgo,
|
||||
},
|
||||
]);
|
||||
|
||||
if (travelTimeSeconds == undefined) return;
|
||||
|
||||
const elapsedTimeMs = referenceCurrentTime.getTime() - originalStopArrival.timestamp.getTime();
|
||||
const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000) + runningTravelTimeSeconds;
|
||||
|
||||
await this.addOrUpdateEta({
|
||||
secondsRemaining: predictedTimeSeconds,
|
||||
shuttleId: shuttle.id,
|
||||
stopId: nextStop.stopId,
|
||||
systemId: nextStop.systemId,
|
||||
updatedTime: new Date(),
|
||||
});
|
||||
|
||||
const nextStopWithNextNextStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, nextStop.stopId);
|
||||
await this.updateCascadingEta(
|
||||
{
|
||||
shuttle,
|
||||
currentStop: nextStopWithNextNextStop,
|
||||
originalStopArrival,
|
||||
runningTravelTimeSeconds: runningTravelTimeSeconds + travelTimeSeconds,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private async handleShuttleWillArriveAtStop({
|
||||
lastStopArrival: lastArrival,
|
||||
willArriveAt: currentArrival,
|
||||
}: ShuttleWillArriveAtStopPayload) {
|
||||
// only update time traveled if last arrival exists
|
||||
if (lastArrival) {
|
||||
// disallow cases where this gets triggered multiple times
|
||||
if (lastArrival.stopId === currentArrival.stopId) return;
|
||||
|
||||
const shuttle = await this.shuttleRepository.getShuttleById(lastArrival.shuttleId);
|
||||
if (!shuttle) return;
|
||||
|
||||
const routeId = shuttle.routeId;
|
||||
const fromStopId = lastArrival.stopId;
|
||||
const toStopId = currentArrival.stopId;
|
||||
|
||||
const travelTimeSeconds = (currentArrival.timestamp.getTime() - lastArrival.timestamp.getTime()) / 1000;
|
||||
await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, currentArrival.timestamp.getTime());
|
||||
}
|
||||
}
|
||||
|
||||
private async handleShuttleWillLeaveStop({
|
||||
stopArrivalThatShuttleIsLeaving,
|
||||
}: ShuttleWillLeaveStopPayload) {
|
||||
await this.removeEtaIfExists(stopArrivalThatShuttleIsLeaving.shuttleId, stopArrivalThatShuttleIsLeaving.stopId);
|
||||
}
|
||||
|
||||
|
||||
public async addTravelTimeDataPoint(
|
||||
{ routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier,
|
||||
travelTimeSeconds: number,
|
||||
timestamp = Date.now(),
|
||||
): Promise<void> {
|
||||
const historicalEtaTimeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId);
|
||||
|
||||
try {
|
||||
await this.redisClient.sendCommand([
|
||||
'TS.ADD',
|
||||
historicalEtaTimeSeriesKey,
|
||||
timestamp.toString(),
|
||||
travelTimeSeconds.toString(),
|
||||
'LABELS',
|
||||
'routeId',
|
||||
routeId,
|
||||
'fromStopId',
|
||||
fromStopId,
|
||||
'toStopId',
|
||||
toStopId
|
||||
]);
|
||||
} catch (error) {
|
||||
await this.createHistoricalEtaTimeSeriesAndAddDataPoint(
|
||||
historicalEtaTimeSeriesKey,
|
||||
timestamp,
|
||||
travelTimeSeconds,
|
||||
routeId,
|
||||
fromStopId,
|
||||
toStopId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async createHistoricalEtaTimeSeriesAndAddDataPoint(
|
||||
timeSeriesKey: string,
|
||||
timestamp: number,
|
||||
travelTimeSeconds: number,
|
||||
routeId: string,
|
||||
fromStopId: string,
|
||||
toStopId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.redisClient.sendCommand([
|
||||
'TS.CREATE',
|
||||
timeSeriesKey,
|
||||
'RETENTION',
|
||||
'2678400000', // one month in milliseconds
|
||||
'LABELS',
|
||||
'routeId',
|
||||
routeId,
|
||||
'fromStopId',
|
||||
fromStopId,
|
||||
'toStopId',
|
||||
toStopId
|
||||
]);
|
||||
await this.redisClient.sendCommand([
|
||||
'TS.ADD',
|
||||
timeSeriesKey,
|
||||
timestamp.toString(),
|
||||
travelTimeSeconds.toString()
|
||||
]);
|
||||
} catch (createError) {
|
||||
await this.redisClient.sendCommand([
|
||||
'TS.ADD',
|
||||
timeSeriesKey,
|
||||
timestamp.toString(),
|
||||
travelTimeSeconds.toString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeEtaIfExists(shuttleId: string, stopId: string): Promise<IEta | null> {
|
||||
const existingEta = await this.getEtaForShuttleAndStopId(shuttleId, stopId);
|
||||
if (existingEta === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = this.createEtaKey(shuttleId, stopId);
|
||||
await this.redisClient.del(key);
|
||||
this.emit(ETARepositoryEvent.ETA_REMOVED, existingEta);
|
||||
return existingEta;
|
||||
}
|
||||
}
|
||||
28
src/repositories/shuttle/eta/SelfUpdatingETARepository.ts
Normal file
28
src/repositories/shuttle/eta/SelfUpdatingETARepository.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository";
|
||||
import { ETAGetterRepository } from "./ETAGetterRepository";
|
||||
|
||||
export interface SelfUpdatingETARepository extends ETAGetterRepository {
|
||||
/**
|
||||
* Attach a event listener to the shuttle repository, listening to
|
||||
* shuttle updates
|
||||
*/
|
||||
startListeningForUpdates(): void;
|
||||
|
||||
/**
|
||||
* Get average travel time between two stops based on historical data.
|
||||
* Returns undefined if no data exists for the specified time range.
|
||||
* @param identifier - The route and stop IDs to query
|
||||
* @param dateFilter - The date range to filter data
|
||||
*/
|
||||
getAverageTravelTimeSeconds(
|
||||
identifier: ShuttleTravelTimeDataIdentifier,
|
||||
dateFilter: ShuttleTravelTimeDateFilterArguments
|
||||
): Promise<number | undefined>;
|
||||
|
||||
/**
|
||||
* Set the "current time" as the class knows it, in order to calculate
|
||||
* ETAs based on past data.
|
||||
* @param referenceTime
|
||||
*/
|
||||
setReferenceTime(referenceTime: Date): void;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder";
|
||||
import { ExternalSourceETARepository } from "../ExternalSourceETARepository";
|
||||
import { RedisExternalSourceETARepository } from "../RedisExternalSourceETARepository";
|
||||
import { InMemoryExternalSourceETARepository } from "../InMemoryExternalSourceETARepository";
|
||||
import { generateMockEtas } from "../../../../../testHelpers/mockDataGenerators";
|
||||
|
||||
class RedisExternalSourceETARepositoryHolder implements RepositoryHolder<ExternalSourceETARepository> {
|
||||
repo: RedisExternalSourceETARepository | undefined;
|
||||
redisClient: RedisClientType | undefined;
|
||||
|
||||
name = "RedisExternalSourceETARepository"
|
||||
factory = async () => {
|
||||
this.redisClient = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
await this.redisClient.connect();
|
||||
this.repo = new RedisExternalSourceETARepository(this.redisClient);
|
||||
return this.repo;
|
||||
}
|
||||
teardown = async () => {
|
||||
if (this.redisClient) {
|
||||
await this.redisClient.flushAll();
|
||||
await this.redisClient.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryExternalSourceETARepositoryHolder implements RepositoryHolder<ExternalSourceETARepository> {
|
||||
repo: InMemoryExternalSourceETARepository | undefined;
|
||||
|
||||
name = "InMemoryExternalSourceETARepository"
|
||||
factory = async () => {
|
||||
this.repo = new InMemoryExternalSourceETARepository();
|
||||
return this.repo;
|
||||
}
|
||||
teardown = async () => {
|
||||
// No teardown needed for in-memory
|
||||
}
|
||||
}
|
||||
|
||||
const repositoryImplementations = [
|
||||
new RedisExternalSourceETARepositoryHolder(),
|
||||
new InMemoryExternalSourceETARepositoryHolder()
|
||||
];
|
||||
|
||||
describe.each(repositoryImplementations)('$name', (holder) => {
|
||||
let repository: ExternalSourceETARepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = await holder.factory();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await holder.teardown();
|
||||
});
|
||||
|
||||
describe("addOrUpdateEtaFromExternalSource", () => {
|
||||
test("adds a new ETA if nonexistent", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
const newEta = mockEtas[0];
|
||||
|
||||
await repository.addOrUpdateEtaFromExternalSource(newEta);
|
||||
|
||||
const result = await repository.getEtasForShuttleId(newEta.shuttleId);
|
||||
expect(result).toEqual([newEta]);
|
||||
});
|
||||
|
||||
test("updates an existing ETA if it exists", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
const existingEta = mockEtas[0];
|
||||
const updatedEta = structuredClone(existingEta);
|
||||
updatedEta.secondsRemaining = existingEta.secondsRemaining + 60;
|
||||
|
||||
await repository.addOrUpdateEtaFromExternalSource(existingEta);
|
||||
await repository.addOrUpdateEtaFromExternalSource(updatedEta);
|
||||
|
||||
const result = await repository.getEtasForShuttleId(existingEta.shuttleId);
|
||||
expect(result).toEqual([updatedEta]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEtasForShuttleId", () => {
|
||||
test("gets ETAs for a specific shuttle ID", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
for (const eta of mockEtas) {
|
||||
await repository.addOrUpdateEtaFromExternalSource(eta);
|
||||
}
|
||||
|
||||
const result = await repository.getEtasForShuttleId("sh1");
|
||||
const expected = mockEtas.filter((eta) => eta.shuttleId === "sh1");
|
||||
expect(result).toHaveLength(expected.length);
|
||||
expect(result).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ETAs for the shuttle ID", async () => {
|
||||
const result = await repository.getEtasForShuttleId("nonexistent-shuttle");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEtasForStopId", () => {
|
||||
test("gets ETAs for a specific stop ID", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
for (const eta of mockEtas) {
|
||||
await repository.addOrUpdateEtaFromExternalSource(eta);
|
||||
}
|
||||
|
||||
const result = await repository.getEtasForStopId("st1");
|
||||
expect(result).toEqual(mockEtas.filter((eta) => eta.stopId === "st1"));
|
||||
});
|
||||
|
||||
test("returns an empty list if there are no ETAs for the stop ID", async () => {
|
||||
const result = await repository.getEtasForStopId("nonexistent-stop");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEtaForShuttleAndStopId", () => {
|
||||
test("gets a single ETA for a specific shuttle and stop ID", async () => {
|
||||
const mockEtas = generateMockEtas();
|
||||
const mockEta = mockEtas[0];
|
||||
await repository.addOrUpdateEtaFromExternalSource(mockEta);
|
||||
|
||||
const result = await repository.getEtaForShuttleAndStopId("sh1", "st1");
|
||||
expect(result).toEqual(mockEta);
|
||||
});
|
||||
|
||||
test("returns null if no ETA matches the shuttle and stop ID", async () => {
|
||||
const result = await repository.getEtaForShuttleAndStopId("nonexistent-shuttle", "nonexistent-stop");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeEtaIfExists", () => {
|
||||
test("removes eta given shuttle ID and stop ID", async () => {
|
||||
let mockEtas = generateMockEtas();
|
||||
const stopId = mockEtas[0].stopId;
|
||||
mockEtas = mockEtas.filter((eta) => eta.stopId === stopId);
|
||||
|
||||
await Promise.all(mockEtas.map(async (eta) => {
|
||||
eta.stopId = stopId;
|
||||
await repository.addOrUpdateEtaFromExternalSource(eta);
|
||||
}));
|
||||
|
||||
const etaToRemove = mockEtas[0];
|
||||
await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId);
|
||||
|
||||
const remainingEtas = await repository.getEtasForStopId(stopId);
|
||||
expect(remainingEtas).toHaveLength(mockEtas.length - 1);
|
||||
});
|
||||
|
||||
test("does nothing if eta doesn't exist", async () => {
|
||||
let mockEtas = generateMockEtas();
|
||||
const stopId = mockEtas[0].stopId;
|
||||
mockEtas = mockEtas.filter((eta) => eta.stopId === stopId);
|
||||
|
||||
await Promise.all(mockEtas.map(async (eta) => {
|
||||
eta.stopId = stopId;
|
||||
await repository.addOrUpdateEtaFromExternalSource(eta);
|
||||
}));
|
||||
|
||||
await repository.removeEtaIfExists("nonexistent-shuttle-id", "nonexistent-stop-id");
|
||||
|
||||
const remainingEtas = await repository.getEtasForStopId(stopId);
|
||||
expect(remainingEtas).toHaveLength(mockEtas.length);
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
||||
import { createClient, RedisClientType } from "redis";
|
||||
import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder";
|
||||
import { SelfUpdatingETARepository } from "../SelfUpdatingETARepository";
|
||||
import { RedisSelfUpdatingETARepository } from "../RedisSelfUpdatingETARepository";
|
||||
import { InMemorySelfUpdatingETARepository } from "../InMemorySelfUpdatingETARepository";
|
||||
import { RedisShuttleRepository } from "../../RedisShuttleRepository";
|
||||
import { UnoptimizedInMemoryShuttleRepository } from "../../UnoptimizedInMemoryShuttleRepository";
|
||||
import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository";
|
||||
import { ShuttleGetterSetterRepository } from "../../ShuttleGetterSetterRepository";
|
||||
import { IShuttle, IStop } from "../../../../entities/ShuttleRepositoryEntities";
|
||||
|
||||
class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder<SelfUpdatingETARepository> {
|
||||
repo: RedisSelfUpdatingETARepository | undefined;
|
||||
shuttleRepo: RedisShuttleRepository | undefined;
|
||||
redisClient: RedisClientType | undefined;
|
||||
|
||||
name = "RedisSelfUpdatingETARepository"
|
||||
factory = async () => {
|
||||
this.redisClient = createClient({
|
||||
url: process.env.REDIS_URL,
|
||||
});
|
||||
await this.redisClient.connect();
|
||||
await this.redisClient.flushAll();
|
||||
this.shuttleRepo = new RedisShuttleRepository(this.redisClient);
|
||||
this.repo = new RedisSelfUpdatingETARepository(
|
||||
this.shuttleRepo,
|
||||
this.redisClient,
|
||||
);
|
||||
return this.repo;
|
||||
}
|
||||
teardown = async () => {
|
||||
if (this.redisClient) {
|
||||
await this.redisClient.flushAll();
|
||||
await this.redisClient.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InMemorySelfUpdatingETARepositoryHolder implements RepositoryHolder<SelfUpdatingETARepository> {
|
||||
repo: InMemorySelfUpdatingETARepository | undefined;
|
||||
shuttleRepo: UnoptimizedInMemoryShuttleRepository | undefined;
|
||||
|
||||
name = "InMemorySelfUpdatingETARepository"
|
||||
factory = async () => {
|
||||
this.shuttleRepo = new UnoptimizedInMemoryShuttleRepository();
|
||||
this.repo = new InMemorySelfUpdatingETARepository(this.shuttleRepo);
|
||||
return this.repo;
|
||||
}
|
||||
teardown = async () => {
|
||||
// No teardown needed for in-memory
|
||||
}
|
||||
}
|
||||
|
||||
const repositoryImplementations = [
|
||||
new RedisSelfUpdatingETARepositoryHolder(),
|
||||
new InMemorySelfUpdatingETARepositoryHolder(),
|
||||
];
|
||||
|
||||
describe.each(repositoryImplementations)('$name', (holder) => {
|
||||
let repository: SelfUpdatingETARepository;
|
||||
let shuttleRepository: ShuttleGetterSetterRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = await holder.factory();
|
||||
shuttleRepository = holder.shuttleRepo!;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await holder.teardown();
|
||||
});
|
||||
|
||||
// Helper function for setting up routes and ordered stops
|
||||
async function setupRouteAndOrderedStops() {
|
||||
return await setupRouteAndOrderedStopsForShuttleRepository(shuttleRepository);
|
||||
}
|
||||
|
||||
async function populateTravelTimeDataForStops({
|
||||
currentTime,
|
||||
shuttle,
|
||||
stop1,
|
||||
stop2,
|
||||
stop3,
|
||||
firstStopArrivalTime = new Date(2025, 0, 1, 11, 0, 0),
|
||||
secondStopArrivalTime = new Date(2025, 0, 1, 11, 15, 0),
|
||||
thirdStopArrivalTime = new Date(2025, 0, 1, 11, 20, 0),
|
||||
}: {
|
||||
currentTime: Date;
|
||||
shuttle: IShuttle;
|
||||
stop1: IStop;
|
||||
stop2: IStop;
|
||||
stop3: IStop;
|
||||
firstStopArrivalTime?: Date;
|
||||
secondStopArrivalTime?: Date;
|
||||
thirdStopArrivalTime?: Date;
|
||||
}) {
|
||||
repository.setReferenceTime(currentTime);
|
||||
repository.startListeningForUpdates();
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime());
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime());
|
||||
|
||||
shuttle.coordinates = stop3.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, thirdStopArrivalTime.getTime());
|
||||
}
|
||||
|
||||
describe("handleShuttleWillArriveAtStop", () => {
|
||||
test("updates how long the shuttle took to get from one stop to another", async () => {
|
||||
const { route, stop2, stop1, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
repository.startListeningForUpdates();
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0);
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime());
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0);
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime());
|
||||
|
||||
// Necessary to wait for the event emitter subscriber to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const travelTime = await repository.getAverageTravelTimeSeconds({
|
||||
routeId: route.id,
|
||||
fromStopId: stop1.id,
|
||||
toStopId: stop2.id,
|
||||
}, {
|
||||
from: new Date(2025, 0, 1, 11, 0, 0),
|
||||
to: new Date(2025, 0, 1, 13, 0, 0),
|
||||
});
|
||||
expect(travelTime).toEqual(15 * 60);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("handleShuttleUpdate", () => {
|
||||
async function assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime(
|
||||
currentTime: Date,
|
||||
shuttleSecondArrivalTimeAtFirstStop: Date
|
||||
) {
|
||||
const { stop1, stop2, stop3, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
// Populating travel time data
|
||||
await populateTravelTimeDataForStops({ currentTime, shuttle, stop1, stop2, stop3 });
|
||||
|
||||
// Populating ETA data
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(
|
||||
shuttle,
|
||||
shuttleSecondArrivalTimeAtFirstStop.getTime()
|
||||
);
|
||||
|
||||
shuttle.coordinates = { latitude: 12.5, longitude: 22.5 };
|
||||
await shuttleRepository.addOrUpdateShuttle(
|
||||
shuttle,
|
||||
currentTime.getTime()
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const secondStopEta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id);
|
||||
expect(secondStopEta?.secondsRemaining).toEqual(8 * 60);
|
||||
|
||||
const thirdStopEta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop3.id);
|
||||
expect(thirdStopEta?.secondsRemaining).toEqual(13 * 60);
|
||||
}
|
||||
|
||||
test("adds ETA entries for stops based on historical data", async () => {
|
||||
const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0);
|
||||
const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000);
|
||||
|
||||
await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime(
|
||||
currentTime, shuttleSecondArrivalTimeAtFirstStop
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
test("uses previous day fallback calculation when no data available from one week ago", async () => {
|
||||
const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 2, 12, 0, 0);
|
||||
const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000);
|
||||
await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime(
|
||||
currentTime, shuttleSecondArrivalTimeAtFirstStop
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
test("uses previous two-hour fallback calculation when no data available from one day ago", async () => {
|
||||
const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 1, 12, 5, 0);
|
||||
const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000);
|
||||
await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime(
|
||||
currentTime, shuttleSecondArrivalTimeAtFirstStop
|
||||
);
|
||||
});
|
||||
|
||||
test("adds a 'stopgap' entry of 1 second when the shuttle arrives at a stop", async () => {
|
||||
const { stop1, stop2, stop3, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 1, 12, 5, 0);
|
||||
const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000);
|
||||
|
||||
// Populating travel time data
|
||||
await populateTravelTimeDataForStops({ currentTime, shuttle, stop1, stop2, stop3 });
|
||||
|
||||
// Populate ETA data
|
||||
// Simulate shuttle running early for second stop
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(
|
||||
shuttle,
|
||||
shuttleSecondArrivalTimeAtFirstStop.getTime()
|
||||
);
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
// Call twice to get the ETA repository to read the correct flag
|
||||
await shuttleRepository.addOrUpdateShuttle(
|
||||
shuttle,
|
||||
currentTime.getTime(),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
await shuttleRepository.addOrUpdateShuttle(
|
||||
shuttle,
|
||||
currentTime.getTime(), // ~8 minutes early
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const eta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id);
|
||||
expect(eta?.secondsRemaining).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleShuttleWillLeaveStop", () => {
|
||||
test("clears ETA of correct stop on leaving stop", async () => {
|
||||
const { stop1, stop2, stop3, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0);
|
||||
const shuttleSecondArrivalTimeAtSecondStop = new Date(2025, 0, 8, 12, 15, 0);
|
||||
const currentTime = new Date(shuttleSecondArrivalTimeAtSecondStop.getTime() + 3 * 60 * 1000);
|
||||
|
||||
await populateTravelTimeDataForStops({ currentTime, shuttle, stop1, stop2, stop3 });
|
||||
|
||||
// Populating ETA data
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, shuttleSecondArrivalTimeAtFirstStop.getTime());
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, shuttleSecondArrivalTimeAtSecondStop.getTime());
|
||||
|
||||
shuttle.coordinates = { latitude: 12.5, longitude: 12.5 }
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, currentTime.getTime());
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const etaForStop3 = await repository.getEtaForShuttleAndStopId(shuttle.id, stop3.id);
|
||||
expect(etaForStop3).not.toBeNull();
|
||||
const etaForStop2 = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id);
|
||||
expect(etaForStop2).toBeNull();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("getAverageTravelTimeSeconds", () => {
|
||||
test("returns the average travel time when historical data exists", async () => {
|
||||
const { route, stop1, stop2, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
repository.startListeningForUpdates();
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
const firstStopTime = new Date(2025, 0, 1, 12, 0, 0);
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopTime.getTime());
|
||||
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
const secondStopTime = new Date(2025, 0, 1, 12, 15, 0);
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopTime.getTime());
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const travelTime = await repository.getAverageTravelTimeSeconds({
|
||||
routeId: route.id,
|
||||
fromStopId: stop1.id,
|
||||
toStopId: stop2.id,
|
||||
}, {
|
||||
from: new Date(2025, 0, 1, 11, 0, 0),
|
||||
to: new Date(2025, 0, 1, 13, 0, 0),
|
||||
});
|
||||
|
||||
expect(travelTime).toEqual(15 * 60);
|
||||
});
|
||||
|
||||
test("returns average of multiple data points", async () => {
|
||||
const { route, stop1, stop2, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
repository.startListeningForUpdates();
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
// First trip: 10 minutes travel time
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime());
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 10, 0).getTime());
|
||||
|
||||
// Second trip: 20 minutes travel time
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 30, 0).getTime());
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 50, 0).getTime());
|
||||
|
||||
const averageTravelTime = await repository.getAverageTravelTimeSeconds({
|
||||
routeId: route.id,
|
||||
fromStopId: stop1.id,
|
||||
toStopId: stop2.id,
|
||||
}, {
|
||||
from: new Date(2025, 0, 1, 11, 0, 0),
|
||||
to: new Date(2025, 0, 1, 14, 0, 0),
|
||||
});
|
||||
|
||||
// Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds
|
||||
expect(averageTravelTime).toBeDefined();
|
||||
});
|
||||
|
||||
test("returns undefined when no data exists", async () => {
|
||||
const { route, stop1, stop2 } = await setupRouteAndOrderedStops();
|
||||
|
||||
repository.startListeningForUpdates();
|
||||
|
||||
const averageTravelTime = await repository.getAverageTravelTimeSeconds({
|
||||
routeId: route.id,
|
||||
fromStopId: stop1.id,
|
||||
toStopId: stop2.id,
|
||||
}, {
|
||||
from: new Date(2025, 0, 1, 11, 0, 0),
|
||||
to: new Date(2025, 0, 1, 14, 0, 0),
|
||||
});
|
||||
|
||||
expect(averageTravelTime).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when querying outside the time range of data", async () => {
|
||||
const { route, stop1, stop2, sampleShuttleNotInRepository: shuttle } = await setupRouteAndOrderedStops();
|
||||
|
||||
repository.startListeningForUpdates();
|
||||
|
||||
shuttle.coordinates = stop1.coordinates;
|
||||
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime());
|
||||
shuttle.coordinates = stop2.coordinates;
|
||||
await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 15, 0).getTime());
|
||||
|
||||
const averageTravelTime = await repository.getAverageTravelTimeSeconds({
|
||||
routeId: route.id,
|
||||
fromStopId: stop1.id,
|
||||
toStopId: stop2.id,
|
||||
}, {
|
||||
from: new Date(2025, 0, 2, 11, 0, 0),
|
||||
to: new Date(2025, 0, 2, 13, 0, 0),
|
||||
});
|
||||
expect(averageTravelTime).toBeUndefined();
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Resolvers } from "../generated/graphql";
|
||||
import { ServerContext } from "../ServerContext";
|
||||
import { HistoricalParkingAverageQueryArguments } from "../repositories/parking/ParkingGetterRepository";
|
||||
import { HistoricalParkingAverageFilterArguments } from "../repositories/parking/ParkingGetterRepository";
|
||||
import { GraphQLError } from "graphql/error";
|
||||
import {
|
||||
PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN,
|
||||
PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL
|
||||
PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN,
|
||||
PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL
|
||||
} from "../environment";
|
||||
|
||||
export const ParkingStructureResolvers: Resolvers<ServerContext> = {
|
||||
@@ -27,7 +27,7 @@ export const ParkingStructureResolvers: Resolvers<ServerContext> = {
|
||||
throwBadUserInputError('No interval provided');
|
||||
return null;
|
||||
}
|
||||
const queryArguments: HistoricalParkingAverageQueryArguments = {
|
||||
const queryArguments: HistoricalParkingAverageFilterArguments = {
|
||||
from: new Date(args.input.from),
|
||||
intervalMs: args.input.intervalMs,
|
||||
to: new Date(args.input.to),
|
||||
|
||||
@@ -12,7 +12,10 @@ export const ParkingSystemResolvers: Resolvers<ServerContext> = {
|
||||
if (!parkingRepository) return [];
|
||||
|
||||
const parkingStructures = await parkingRepository.getParkingStructures();
|
||||
return parkingStructures.map((structure) => {
|
||||
return parkingStructures
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((structure) => {
|
||||
return {
|
||||
...structure,
|
||||
systemId: parent.systemId
|
||||
|
||||
@@ -7,12 +7,15 @@ const GRAPHQL_SCHEMA_CURRENT_VERSION = 1;
|
||||
export const QueryResolvers: Resolvers<ServerContext> = {
|
||||
Query: {
|
||||
systems: async (_parent, args, contextValue, _info) => {
|
||||
return contextValue.systems.map((system) => {
|
||||
return {
|
||||
name: system.name,
|
||||
id: system.id,
|
||||
};
|
||||
})
|
||||
return contextValue.systems
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((system) => {
|
||||
return {
|
||||
name: system.name,
|
||||
id: system.id,
|
||||
};
|
||||
})
|
||||
},
|
||||
system: async (_parent, args, contextValue, _info) => {
|
||||
if (!args.id) return null;
|
||||
|
||||
@@ -9,7 +9,10 @@ export const RouteResolvers: Resolvers<ServerContext> = {
|
||||
|
||||
const shuttles = await system.shuttleRepository.getShuttlesByRouteId(parent.id);
|
||||
|
||||
return shuttles.map(({
|
||||
return shuttles
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(({
|
||||
coordinates,
|
||||
name,
|
||||
id,
|
||||
@@ -51,7 +54,10 @@ export const RouteResolvers: Resolvers<ServerContext> = {
|
||||
if (!system) return null;
|
||||
|
||||
const orderedStops = await system.shuttleRepository.getOrderedStopsByRouteId(parent.id);
|
||||
return orderedStops.map(({ routeId, stopId, position, systemId, updatedTime }) => ({
|
||||
return orderedStops
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(({ routeId, stopId, position, systemId, updatedTime }) => ({
|
||||
routeId,
|
||||
stopId,
|
||||
position,
|
||||
|
||||
@@ -3,13 +3,13 @@ import { ServerContext } from "../ServerContext";
|
||||
|
||||
export const ShuttleResolvers: Resolvers<ServerContext> = {
|
||||
Shuttle: {
|
||||
eta: async (parent, args, contextValue, info) => {
|
||||
eta: async (parent, args, contextValue, _) => {
|
||||
if (!args.forStopId) return null;
|
||||
|
||||
const system = contextValue.findSystemById(parent.systemId);
|
||||
if (!system) return null;
|
||||
|
||||
const etaForStopId = await system.shuttleRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId);
|
||||
const etaForStopId = await system.etaRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId);
|
||||
if (etaForStopId === null) return null;
|
||||
|
||||
return {
|
||||
@@ -21,11 +21,11 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
|
||||
updatedTimeMs: etaForStopId.updatedTime,
|
||||
};
|
||||
},
|
||||
etas: async (parent, args, contextValue, info) => {
|
||||
etas: async (parent, args, contextValue, _) => {
|
||||
const system = contextValue.findSystemById(parent.systemId);
|
||||
if (!system) return null;
|
||||
|
||||
const etasForShuttle = await system.shuttleRepository.getEtasForShuttleId(parent.id);
|
||||
const etasForShuttle = await system.etaRepository.getEtasForShuttleId(parent.id);
|
||||
if (!etasForShuttle) return null;
|
||||
|
||||
const computedEtas = await Promise.all(
|
||||
@@ -45,12 +45,14 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
|
||||
}));
|
||||
|
||||
if (computedEtas.every((eta) => eta !== null)) {
|
||||
return computedEtas;
|
||||
return computedEtas
|
||||
.slice()
|
||||
.sort((a, b) => (a!.secondsRemaining - b!.secondsRemaining));
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
route: async (parent, args, contextValue, info) => {
|
||||
route: async (parent, args, contextValue, _) => {
|
||||
const system = contextValue.findSystemById(parent.systemId);
|
||||
if (!system) return null;
|
||||
|
||||
|
||||
@@ -8,14 +8,16 @@ export const StopResolvers: Resolvers<ServerContext> = {
|
||||
if (!system) {
|
||||
return [];
|
||||
}
|
||||
return await system.shuttleRepository.getOrderedStopsByStopId(parent.id);
|
||||
const orderedStops = await system.shuttleRepository.getOrderedStopsByStopId(parent.id);
|
||||
return orderedStops.slice().sort((a, b) => a.position - b.position);
|
||||
},
|
||||
etas: async (parent, args, contextValue, _info) => {
|
||||
const system = contextValue.findSystemById(parent.systemId);
|
||||
if (!system) {
|
||||
return [];
|
||||
}
|
||||
return await system.shuttleRepository.getEtasForStopId(parent.id);
|
||||
const etas = await system.etaRepository.getEtasForStopId(parent.id);
|
||||
return etas.slice().sort((a, b) => a.secondsRemaining - b.secondsRemaining);
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ export const SystemResolvers: Resolvers<ServerContext> = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await system.shuttleRepository.getRoutes();
|
||||
const routes = await system.shuttleRepository.getRoutes();
|
||||
return routes.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
stops: async (parent, _args, contextValue, _info) => {
|
||||
const system = contextValue.findSystemById(parent.id);
|
||||
@@ -17,7 +18,8 @@ export const SystemResolvers: Resolvers<ServerContext> = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await system.shuttleRepository.getStops();
|
||||
const stops = await system.shuttleRepository.getStops();
|
||||
return stops.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
stop: async (parent, args, contextValue, _info) => {
|
||||
if (!args.id) return null;
|
||||
@@ -78,7 +80,16 @@ export const SystemResolvers: Resolvers<ServerContext> = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await system.shuttleRepository.getShuttles();
|
||||
const shuttles = await system.shuttleRepository.getShuttles();
|
||||
return shuttles.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
initialRegion: async (parent, _args, contextValue, _info) => {
|
||||
const system = contextValue.findSystemById(parent.id);
|
||||
if (!system) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return system.initialRegion;
|
||||
},
|
||||
parkingSystem: async (parent, _args, contextValue, _info) => {
|
||||
const system = contextValue.findSystemById(parent.id);
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
addMockShuttleToRepository,
|
||||
addMockStopToRepository,
|
||||
} from "../../../testHelpers/repositorySetupHelpers";
|
||||
import assert = require("node:assert");
|
||||
import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("EtaResolvers", () => {
|
||||
const holder = setupTestServerHolder();
|
||||
@@ -19,7 +20,7 @@ describe("EtaResolvers", () => {
|
||||
beforeEach(async () => {
|
||||
mockShuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, context.systems[0].id);
|
||||
mockStop = await addMockStopToRepository(context.systems[0].shuttleRepository, context.systems[0].id);
|
||||
expectedEta = await addMockEtaToRepository(context.systems[0].shuttleRepository, mockStop.id, mockShuttle.id);
|
||||
expectedEta = await addMockEtaToRepository(context.systems[0].etaRepository as ExternalSourceETARepository, mockStop.id, mockShuttle.id);
|
||||
});
|
||||
|
||||
async function getResponseForEtaQuery(query: string) {
|
||||
|
||||
@@ -81,6 +81,49 @@ describe("ParkingSystemResolver", () => {
|
||||
|
||||
expect(parkingStructures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns parking structures sorted by name", async () => {
|
||||
// Create two out-of-order structures by name
|
||||
const s1 = {
|
||||
id: "ps1",
|
||||
name: "Barrera",
|
||||
capacity: 100,
|
||||
spotsAvailable: 50,
|
||||
coordinates: { latitude: 0, longitude: 0 },
|
||||
address: "1 Anywhere",
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
const s2 = {
|
||||
id: "ps2",
|
||||
name: "Anderson",
|
||||
capacity: 100,
|
||||
spotsAvailable: 50,
|
||||
coordinates: { latitude: 0, longitude: 0 },
|
||||
address: "2 Anywhere",
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(s1);
|
||||
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(s2);
|
||||
|
||||
const minimalQuery = `
|
||||
query GetParkingStructuresBySystem($systemId: ID!) {
|
||||
system(id: $systemId) {
|
||||
parkingSystem {
|
||||
parkingStructures {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await getResponseFromQueryNeedingSystemId(minimalQuery);
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const names = (response.body.singleResult.data as any).system.parkingSystem.parkingStructures.map((p: any) => p.name);
|
||||
expect(names).toEqual([s2.name, s1.name]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,41 @@ describe("QueryResolvers", () => {
|
||||
|
||||
expect(response.body.singleResult.data?.systems).toHaveLength(systems.length);
|
||||
});
|
||||
|
||||
it("returns systems sorted by name", async () => {
|
||||
const s1 = buildSystemForTesting();
|
||||
const s2 = buildSystemForTesting();
|
||||
const s3 = buildSystemForTesting();
|
||||
s1.name = "Chapman University";
|
||||
s1.id = "a";
|
||||
s2.name = "City of Monterey";
|
||||
s2.id = "b";
|
||||
s3.name = "Cal State Long Beach";
|
||||
s3.id = "c";
|
||||
|
||||
context.systems = [s1, s2, s3];
|
||||
|
||||
const query = `
|
||||
query GetSystems
|
||||
{
|
||||
systems {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await holder.testServer.executeOperation({
|
||||
query,
|
||||
}, {
|
||||
contextValue: context
|
||||
|
||||
});
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const names = (response.body.singleResult.data as any).systems.map((s: any) => s.name);
|
||||
expect(names).toEqual(["Cal State Long Beach", "Chapman University", "City of Monterey"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system", () => {
|
||||
|
||||
@@ -79,6 +79,27 @@ describe("RouteResolvers", () => {
|
||||
any).system.route.shuttles;
|
||||
expect(shuttles.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("returns shuttles sorted by name", async () => {
|
||||
const shuttles = generateMockShuttles();
|
||||
// use two shuttles for determinism
|
||||
const s1 = { ...shuttles[0], name: "Zed" };
|
||||
const s2 = { ...shuttles[1], name: "Alpha" };
|
||||
s1.systemId = mockSystem.id;
|
||||
s1.routeId = mockRoute.id;
|
||||
s2.systemId = mockSystem.id;
|
||||
s2.routeId = mockRoute.id;
|
||||
|
||||
await context.systems[0].shuttleRepository.addOrUpdateShuttle(s1);
|
||||
await context.systems[0].shuttleRepository.addOrUpdateShuttle(s2);
|
||||
|
||||
const response = await getResponseForShuttlesQuery();
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined()
|
||||
const names = (response.body.singleResult.data as any).system.route.shuttles.map((s: any) => s.name);
|
||||
expect(names).toEqual(["Alpha", "Zed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderedStop", () => {
|
||||
@@ -199,5 +220,22 @@ describe("RouteResolvers", () => {
|
||||
const retrievedOrderedStops = (response.body.singleResult.data as any).system.route.orderedStops;
|
||||
expect(retrievedOrderedStops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns ordered stops sorted by position", async () => {
|
||||
const stops = generateMockOrderedStops().slice(0, 3).map((s) => ({ ...s }));
|
||||
// Force same routeId and distinct positions out of order
|
||||
stops[0].routeId = mockRoute.id; stops[0].position = 3; stops[0].stopId = "stA";
|
||||
stops[1].routeId = mockRoute.id; stops[1].position = 1; stops[1].stopId = "stB";
|
||||
stops[2].routeId = mockRoute.id; stops[2].position = 2; stops[2].stopId = "stC";
|
||||
await Promise.all(stops.map(s => context.systems[0].shuttleRepository.addOrUpdateOrderedStop(s)));
|
||||
|
||||
const response = await getResponseForOrderedStopsQuery();
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const stopIds = (response.body.singleResult.data as any).system.route.orderedStops.map((s: any) => s.stopId);
|
||||
const expectedOrder = [...stops].sort((a, b) => a.position - b.position).map(s => s.stopId);
|
||||
expect(stopIds).toEqual(expectedOrder);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@ import { generateMockEtas, generateMockRoutes } from "../../../testHelpers/mockD
|
||||
import { IShuttle } from "../../entities/ShuttleRepositoryEntities";
|
||||
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
|
||||
import { addMockShuttleToRepository } from "../../../testHelpers/repositorySetupHelpers";
|
||||
import assert = require("node:assert");
|
||||
import { InterchangeSystem } from "../../entities/InterchangeSystem";
|
||||
import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository";
|
||||
import assert from "node:assert";
|
||||
|
||||
|
||||
describe("ShuttleResolvers", () => {
|
||||
@@ -25,7 +26,7 @@ describe("ShuttleResolvers", () => {
|
||||
const etas = generateMockEtas();
|
||||
await Promise.all(etas.map(async (eta) => {
|
||||
eta.shuttleId = shuttleId;
|
||||
await context.systems[0].shuttleRepository.addOrUpdateEta(eta);
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(eta);
|
||||
}));
|
||||
return etas;
|
||||
}
|
||||
@@ -141,6 +142,31 @@ describe("ShuttleResolvers", () => {
|
||||
any).system.shuttle.etas).toHaveLength(0);
|
||||
|
||||
});
|
||||
|
||||
it("returns ETAs sorted by secondsRemaining", async () => {
|
||||
const e1 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stA", secondsRemaining: 300 };
|
||||
const e2 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stB", secondsRemaining: 30 };
|
||||
const e3 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stC", secondsRemaining: 120 };
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e1);
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e2);
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e3);
|
||||
|
||||
const response = await holder.testServer.executeOperation({
|
||||
query,
|
||||
variables: {
|
||||
systemId: mockSystem.id,
|
||||
shuttleId: mockShuttle.id,
|
||||
},
|
||||
}, {
|
||||
contextValue: context
|
||||
|
||||
});
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const seconds = (response.body.singleResult.data as any).system.shuttle.etas.map((e: any) => e.secondsRemaining);
|
||||
expect(seconds).toEqual([30, 120, 300]);
|
||||
});
|
||||
});
|
||||
describe("route", () => {
|
||||
const query = `
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
import { generateMockEtas, generateMockOrderedStops } from "../../../testHelpers/mockDataGenerators";
|
||||
import { IStop } from "../../entities/ShuttleRepositoryEntities";
|
||||
import { addMockStopToRepository } from "../../../testHelpers/repositorySetupHelpers";
|
||||
import assert = require("node:assert");
|
||||
import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("StopResolvers", () => {
|
||||
const holder = setupTestServerHolder();
|
||||
@@ -67,6 +68,25 @@ describe("StopResolvers", () => {
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
expect((response.body.singleResult.data as any).system.stop.orderedStops).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns ordered stops sorted by position", async () => {
|
||||
// Create three ordered stops with out-of-order positions and distinct routeIds
|
||||
const base = generateMockOrderedStops()[0];
|
||||
const o1 = { ...base, stopId: mockStop.id, routeId: "rA", position: 3 };
|
||||
const o2 = { ...base, stopId: mockStop.id, routeId: "rB", position: 1 };
|
||||
const o3 = { ...base, stopId: mockStop.id, routeId: "rC", position: 2 };
|
||||
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(o1);
|
||||
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(o2);
|
||||
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(o3);
|
||||
|
||||
const response = await getResponseForQuery(query);
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const routeIds = (response.body.singleResult.data as any).system.stop.orderedStops.map((s: any) => s.routeId);
|
||||
const expectedOrder = [o2, o3, o1].map(s => s.routeId);
|
||||
expect(routeIds).toEqual(expectedOrder);
|
||||
});
|
||||
});
|
||||
|
||||
describe("etas", () => {
|
||||
@@ -87,7 +107,7 @@ describe("StopResolvers", () => {
|
||||
mockEtas = mockEtas.filter((eta) => eta.stopId === mockEtas[0].stopId);
|
||||
await Promise.all(mockEtas.map(async eta => {
|
||||
eta.stopId = mockStop.id;
|
||||
await context.systems[0].shuttleRepository.addOrUpdateEta(eta);
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(eta);
|
||||
}));
|
||||
|
||||
const response = await getResponseForQuery(query);
|
||||
@@ -104,5 +124,21 @@ describe("StopResolvers", () => {
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
expect((response.body.singleResult.data as any).system.stop.etas).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns ETAs sorted by secondsRemaining", async () => {
|
||||
const e1 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shA", secondsRemaining: 240 };
|
||||
const e2 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shB", secondsRemaining: 60 };
|
||||
const e3 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shC", secondsRemaining: 120 };
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e1);
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e2);
|
||||
await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e3);
|
||||
|
||||
const response = await getResponseForQuery(query);
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const seconds = (response.body.singleResult.data as any).system.stop.etas.map((e: any) => e.secondsRemaining);
|
||||
expect(seconds).toEqual([60, 120, 240]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
generateMockRoutes,
|
||||
generateMockShuttles,
|
||||
generateMockStops,
|
||||
generateParkingStructures
|
||||
} from "../../../testHelpers/mockDataGenerators";
|
||||
import {
|
||||
addMockRouteToRepository,
|
||||
@@ -36,6 +35,37 @@ describe("SystemResolvers", () => {
|
||||
});
|
||||
}
|
||||
|
||||
describe("initialRegion", () => {
|
||||
const query = `
|
||||
query GetSystemInitialRegion($systemId: ID!) {
|
||||
system(id: $systemId) {
|
||||
initialRegion {
|
||||
topLeft {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
bottomRight {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
it("returns the initial region for the system", async () => {
|
||||
const response = await getResponseFromQueryNeedingSystemId(query);
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const initialRegion = (response.body.singleResult.data as any).system.initialRegion;
|
||||
expect(initialRegion).toEqual({
|
||||
topLeft: { latitude: 33.85733, longitude: -117.89553 },
|
||||
bottomRight: { latitude: 33.7397, longitude: -117.81878 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes", () => {
|
||||
const query = `
|
||||
query GetSystemRoutes($systemId: ID!) {
|
||||
@@ -62,6 +92,23 @@ describe("SystemResolvers", () => {
|
||||
const routes = (response.body.singleResult.data as any).system.routes;
|
||||
expect(routes.length === expectedRoutes.length);
|
||||
});
|
||||
|
||||
it("returns routes sorted by name", async () => {
|
||||
const routes = generateMockRoutes();
|
||||
// Insert in reverse name order to verify sorting
|
||||
const reversed = [...routes].sort((a, b) => b.name.localeCompare(a.name));
|
||||
await Promise.all(reversed.map(async (route) => {
|
||||
route.systemId = mockSystem.id;
|
||||
await context.systems[0].shuttleRepository.addOrUpdateRoute(route);
|
||||
}));
|
||||
|
||||
const response = await getResponseFromQueryNeedingSystemId(query);
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const names = (response.body.singleResult.data as any).system.routes.map((r: any) => r.name);
|
||||
expect(names).toEqual([...routes].sort((a, b) => a.name.localeCompare(b.name)).map(r => r.name));
|
||||
});
|
||||
});
|
||||
|
||||
describe("stops", () => {
|
||||
@@ -90,6 +137,22 @@ describe("SystemResolvers", () => {
|
||||
const stops = (response.body.singleResult.data as any).system.stops;
|
||||
expect(stops.length === expectedStops.length);
|
||||
});
|
||||
|
||||
it("returns stops sorted by name", async () => {
|
||||
const stops = generateMockStops();
|
||||
const reversed = [...stops].sort((a, b) => b.name.localeCompare(a.name));
|
||||
await Promise.all(reversed.map(async (stop) => {
|
||||
stop.systemId = mockSystem.id;
|
||||
await context.systems[0].shuttleRepository.addOrUpdateStop(stop);
|
||||
}));
|
||||
|
||||
const response = await getResponseFromQueryNeedingSystemId(query);
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const names = (response.body.singleResult.data as any).system.stops.map((s: any) => s.name);
|
||||
expect(names).toEqual([...stops].sort((a, b) => a.name.localeCompare(b.name)).map(s => s.name));
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop", () => {
|
||||
@@ -312,6 +375,22 @@ describe("SystemResolvers", () => {
|
||||
const shuttles = (response.body.singleResult.data as any).system.shuttles;
|
||||
expect(shuttles.length === expectedShuttles.length);
|
||||
});
|
||||
|
||||
it("returns shuttles sorted by name", async () => {
|
||||
const shuttles = generateMockShuttles();
|
||||
const reversed = [...shuttles].sort((a, b) => b.name.localeCompare(a.name));
|
||||
await Promise.all(reversed.map(async (shuttle) => {
|
||||
shuttle.systemId = mockSystem.id;
|
||||
await context.systems[0].shuttleRepository.addOrUpdateShuttle(shuttle);
|
||||
}));
|
||||
|
||||
const response = await getResponseFromQueryNeedingSystemId(query);
|
||||
|
||||
assert(response.body.kind === "single");
|
||||
expect(response.body.singleResult.errors).toBeUndefined();
|
||||
const names = (response.body.singleResult.data as any).system.shuttles.map((s: any) => s.name);
|
||||
expect(names).toEqual([...shuttles].sort((a, b) => a.name.localeCompare(b.name)).map(s => s.name));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
5
testHelpers/RepositoryHolder.ts
Normal file
5
testHelpers/RepositoryHolder.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface RepositoryHolder<T> {
|
||||
name: string;
|
||||
factory(): Promise<T>;
|
||||
teardown(): Promise<void>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ApolloServer } from "@apollo/server";
|
||||
import { MergedResolvers } from "../src/MergedResolvers";
|
||||
import { beforeEach } from "@jest/globals";
|
||||
import { ServerContext } from "../src/ServerContext";
|
||||
import { InterchangeSystem } from "../src/entities/InterchangeSystem";
|
||||
import { InterchangeSystem, InterchangeSystemBuilderArguments } from "../src/entities/InterchangeSystem";
|
||||
import {
|
||||
ChapmanApiBasedParkingRepositoryLoader
|
||||
} from "../src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader";
|
||||
@@ -19,11 +19,17 @@ function setUpTestServer() {
|
||||
});
|
||||
}
|
||||
|
||||
const systemInfoForTesting = {
|
||||
const systemInfoForTesting: InterchangeSystemBuilderArguments = {
|
||||
id: "1",
|
||||
name: "Chapman University",
|
||||
passioSystemId: "263",
|
||||
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
|
||||
useSelfUpdatingEtas: false,
|
||||
shuttleStopArrivalDegreeDelta: 0.001,
|
||||
initialRegion: {
|
||||
topLeft: { latitude: 33.85733, longitude: -117.89553 },
|
||||
bottomRight: { latitude: 33.73970, longitude: -117.81878 },
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSystemForTesting() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
generateMockStops,
|
||||
} from "./mockDataGenerators";
|
||||
import { ShuttleGetterSetterRepository } from "../src/repositories/shuttle/ShuttleGetterSetterRepository";
|
||||
import { ExternalSourceETARepository } from "../src/repositories/shuttle/eta/ExternalSourceETARepository";
|
||||
|
||||
export async function addMockRouteToRepository(repository: ShuttleGetterSetterRepository, systemId: string) {
|
||||
const mockRoutes = generateMockRoutes();
|
||||
@@ -32,12 +33,12 @@ export async function addMockShuttleToRepository(repository: ShuttleGetterSetter
|
||||
return mockShuttle;
|
||||
}
|
||||
|
||||
export async function addMockEtaToRepository(repository: ShuttleGetterSetterRepository, stopId: string, shuttleId: string) {
|
||||
export async function addMockEtaToRepository(repository: ExternalSourceETARepository, stopId: string, shuttleId: string) {
|
||||
const etas = generateMockEtas();
|
||||
const expectedEta = etas[0];
|
||||
expectedEta.stopId = stopId;
|
||||
expectedEta.shuttleId = shuttleId;
|
||||
await repository.addOrUpdateEta(expectedEta);
|
||||
await repository.addOrUpdateEtaFromExternalSource(expectedEta);
|
||||
|
||||
return expectedEta;
|
||||
}
|
||||
|
||||
92
testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts
Normal file
92
testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { IOrderedStop, IShuttle, IStop } from "../src/entities/ShuttleRepositoryEntities";
|
||||
import { ShuttleGetterSetterRepository } from "../src/repositories/shuttle/ShuttleGetterSetterRepository";
|
||||
|
||||
export async function setupRouteAndOrderedStopsForShuttleRepository(
|
||||
shuttleRepository: ShuttleGetterSetterRepository
|
||||
) {
|
||||
const systemId = "sys1";
|
||||
const route = {
|
||||
id: "r1",
|
||||
name: "Route 1",
|
||||
color: "red",
|
||||
systemId: systemId,
|
||||
polylineCoordinates: [],
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
await shuttleRepository.addOrUpdateRoute(route);
|
||||
|
||||
const stop1: IStop = {
|
||||
id: "st1",
|
||||
name: "Stop 1",
|
||||
systemId,
|
||||
coordinates: { latitude: 10.0, longitude: 20.0 },
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
const stop2: IStop = {
|
||||
id: "st2",
|
||||
name: "Stop 2",
|
||||
systemId,
|
||||
coordinates: { latitude: 15.0, longitude: 25.0 },
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
const stop3: IStop = {
|
||||
id: "st3",
|
||||
name: "Stop 3",
|
||||
systemId,
|
||||
coordinates: { latitude: 20.0, longitude: 30.0 },
|
||||
updatedTime: new Date(),
|
||||
}
|
||||
await shuttleRepository.addOrUpdateStop(stop1);
|
||||
await shuttleRepository.addOrUpdateStop(stop2);
|
||||
await shuttleRepository.addOrUpdateStop(stop3);
|
||||
|
||||
const orderedStop1: IOrderedStop = {
|
||||
routeId: route.id,
|
||||
stopId: stop1.id,
|
||||
position: 1,
|
||||
systemId,
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
const orderedStop2: IOrderedStop = {
|
||||
routeId: route.id,
|
||||
stopId: stop2.id,
|
||||
position: 2,
|
||||
systemId,
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
const orderedStop3: IOrderedStop = {
|
||||
routeId: route.id,
|
||||
stopId: stop3.id,
|
||||
position: 3,
|
||||
systemId,
|
||||
updatedTime: new Date(),
|
||||
}
|
||||
orderedStop1.nextStop = orderedStop2;
|
||||
orderedStop1.previousStop = orderedStop3;
|
||||
orderedStop2.nextStop = orderedStop3;
|
||||
orderedStop2.previousStop = orderedStop1;
|
||||
orderedStop3.nextStop = orderedStop1;
|
||||
orderedStop3.previousStop = orderedStop2;
|
||||
await shuttleRepository.addOrUpdateOrderedStop(orderedStop1);
|
||||
await shuttleRepository.addOrUpdateOrderedStop(orderedStop2);
|
||||
await shuttleRepository.addOrUpdateOrderedStop(orderedStop3);
|
||||
|
||||
const sampleShuttleNotInRepository: IShuttle = {
|
||||
id: "sh1",
|
||||
name: "Shuttle 1",
|
||||
routeId: route.id,
|
||||
systemId: systemId,
|
||||
coordinates: stop2.coordinates,
|
||||
orientationInDegrees: 0,
|
||||
updatedTime: new Date(),
|
||||
};
|
||||
|
||||
return {
|
||||
route,
|
||||
systemId,
|
||||
stop1,
|
||||
stop2,
|
||||
stop3,
|
||||
sampleShuttleNotInRepository,
|
||||
};
|
||||
}
|
||||
15
tsconfig.build.json
Normal file
15
tsconfig.build.json
Normal file
@@ -0,0 +1,15 @@
|
||||
// For builds, excludes tests and mocks
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["**/__tests__/*/**", "**/__mocks__/*/**"]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// For type-checking, includes tests and mocks
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
@@ -10,5 +11,4 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["**/__tests__/*/**", "**/__mocks__/*/**"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user