172 Commits

Author SHA1 Message Date
457722a79c Set Docker host for deployment workflow 2026-02-23 12:39:33 -08:00
5cd2e0cc59 Merge pull request #103 from brendan-ch/chore/add-self-hosted-deploy-script
chore/add-self-hosted-deploy-workflow
2026-02-23 12:35:20 -08:00
12951c96cb Add self-hosted deploy workflow 2026-02-23 12:32:04 -08:00
da1a57ba70 Merge pull request #102 from brendan-ch/chore/setup-self-hosted
chore/setup-self-hosted
2026-02-23 12:12:29 -08:00
b1420cfa7e Add REDIS_USE_TLS environment variable to configure TLS for Redis 2026-02-23 12:06:10 -08:00
ecc9cdd747 Add Docker Compose file which uses Caddy 2026-02-23 12:01:28 -08:00
471121aa0f Add Caddyfile 2026-02-23 12:00:49 -08:00
f3cc9ec4f5 Merge pull request #99 from brendan-ch/hotfix/use-in-memory-shuttle-and-eta-repositories
hotfix/use-in-memory-shuttle-and-eta-repositories
2025-12-06 09:31:42 -08:00
4e35498be7 Switch to using in-memory shuttle and ETA repositories
This is to improve performance until Redis is migrated to the same memory space as the server.
2025-12-06 09:22:24 -08:00
91bdb7d0e3 Merge pull request #96 from brendan-ch:feat/next-stop-eta-lock
feat/next-stop-eta-lock
2025-11-23 20:38:37 -08:00
298bf229f8 Mark shuttle as not at stop when removing a shuttle 2025-11-23 20:29:50 -08:00
19cdbec42b Refactor all Redis keys to use variables, and add clearing of shuttle isAtStop set on shuttle clear 2025-11-23 20:29:06 -08:00
8d2dd60cfb Remove the unsupported shuttleStopNearbyDegreeDelta option from InterchangeSystemBuilderArguments 2025-11-22 18:21:22 -08:00
ee10daf957 Rename getArrivedStopIfExists to getArrivedStopIfNextStop, and add more documentation 2025-11-21 11:42:42 -08:00
715bef163c Add stopgap ETA of 1 second when the shuttle has arrived at a stop 2025-11-21 11:33:22 -08:00
6c199e38a8 Test previously failing case for SHUTTLE_WILL_LEAVE_STOP 2025-11-21 11:10:10 -08:00
413443a162 Update the method to accept a canReturnShuttleCurrentStop flag instead 2025-11-21 11:07:47 -08:00
8c341e91e0 Call getArrivedStopIfExists based on different parameter 2025-11-21 10:57:04 -08:00
712e311006 Update getArrivedStopIfExists method to take an argument returnNextStopOnly 2025-11-21 10:51:38 -08:00
c2f1a67a70 Remove unused ETA code from RedisShuttleRepository 2025-11-21 10:39:08 -08:00
f34f78aaea Remove the delta argument in ShuttleGetterRepository.getArrivedStopIfExists 2025-11-20 19:07:30 -08:00
37fbc3ef45 Make checkIfShuttleIsAtStop part of the ShuttleGetterRepository interface 2025-11-20 17:48:49 -08:00
ddab9b96ff Remove SHUTTLE_IS_NEAR_NEXT_STOP event and placeholder tests 2025-11-20 17:47:13 -08:00
22322e5f0a Add a separate tsconfig for excluding tests/mocks from builds, while leaving them for type checks 2025-11-20 16:52:56 -08:00
a9db9b5d5c Pass down the new arguments into the shuttle repositories 2025-11-20 16:47:50 -08:00
645fe1055b Add createRedisClientForRepository method to deduplicate default Redis client in constructor 2025-11-20 16:41:14 -08:00
b202189ad6 Fix typo in "shuttle" 2025-11-20 16:29:28 -08:00
1d98e8450e Update InterchangeSystemBuilderArguments with new properties for controlling event emitter degree delta 2025-11-20 16:26:25 -08:00
fb01406a29 Add placeholder test cases for SHUTTLE_IS_NEAR_NEXT_STOP event 2025-11-19 11:51:58 -08:00
2ff71b0dd1 Define a new event to indicate shuttle is near a stop 2025-11-19 11:48:22 -08:00
dbcc882a18 Only update ETAs in shuttle repository loader if repository was injected 2025-11-19 11:43:04 -08:00
545eaef74a Add implementation and test for leaving stop event 2025-11-19 11:31:13 -08:00
b8c3f17510 Add test for ETA clearing 2025-11-19 10:57:27 -08:00
83671e2b22 Add logic to fire the SHUTTLE_WILL_LEAVE_STOP event 2025-11-18 21:06:10 -08:00
c8c5aa28c6 Add a method to mark a shuttle as not at a stop 2025-11-18 20:42:06 -08:00
78cf60af4a Add code to mark shuttle at a stop (Redis set) 2025-11-18 20:27:00 -08:00
4ffdd35b21 Add other tests (one for multiple events, one for not emitting the event) 2025-11-18 20:02:06 -08:00
8d9ff3ac62 Add a test for emitting the event 2025-11-18 19:57:15 -08:00
47708d050e Move duplicate shuttle declarations in tests to a "sample" shuttle generated by the setup function 2025-11-18 19:50:07 -08:00
f334054b5e Add SHUTTLE_WILL_LEAVE_STOP event and upload payload names 2025-11-18 19:17:04 -08:00
34b2ab05d4 Rename properties in WillArriveAtStopPayload for more clarity 2025-11-18 19:14:29 -08:00
0798773bcb Replicate the change in the in-memory version 2025-11-18 19:07:44 -08:00
a2f76703ea Remove behavior of ETA clearing when shuttle is approaching stop 2025-11-18 19:07:06 -08:00
0af54cd48f Merge pull request #89 from brendan-ch/chore/update-node-version
chore/update-node-version
2025-11-14 11:15:38 -08:00
1dfdfeff1e Update package-lock and dockerfile 2025-11-14 11:05:47 -08:00
1df69b3a73 Add Node.js version to package.json 2025-11-14 11:02:55 -08:00
5f3ca479aa Specify node version in nvmrc 2025-11-14 11:02:26 -08:00
e1a389101f Merge pull request #88 from brendan-ch:hotfix/use-two-hour-windows-for-all-fallbacks
hotfix/use-two-hour-windows-for-all-fallbacks
2025-11-14 10:54:14 -08:00
8621f10721 Switch the order of fallbacks (previous 2h first)
Data from the current day is more representative of current traffic conditions than the same data from a previous day.
2025-11-14 10:49:23 -08:00
f8e7670b3f For data from last week and previous day, look back instead of looking forward 2025-11-14 10:40:37 -08:00
397eb2b588 Change one-hour windows to two hours
A set of shuttles may only make a complete set of trips in two hours, so this should capture more data when calculating ETAs.
2025-11-14 10:34:48 -08:00
b2ebd3df9c Merge pull request #87 from brendan-ch/hotfix/clear-shuttle-last-stop-if-no-data
hotfix/clear-shuttle-last-stop-if-no-data
2025-11-14 10:31:33 -08:00
f06e778b80 Implement removal of last stop data on shuttle removal 2025-11-14 10:26:52 -08:00
1511b3c118 Add test case to check that last stop data is removed 2025-11-14 10:23:02 -08:00
f76126c951 Implement clearing of last stop data 2025-11-14 10:19:15 -08:00
4523badea9 Test that clearShuttleData also clears last stop data 2025-11-14 10:17:33 -08:00
05fc03c032 Merge pull request #86 from brendan-ch/feat/self-calculated-etas
feat/self-calculated-etas
2025-11-13 22:38:51 -08:00
a89d3c7ae3 Fix test references 2025-11-13 22:36:13 -08:00
6278d695fa Add support back in for external source ETA repositories, if we switch back later 2025-11-13 22:28:07 -08:00
b74a0d5d64 Restrict stop arrival event to only next stop after shuttle's last stop, if last stop exists 2025-11-13 22:13:17 -08:00
2df8f389b3 Change the final fallback window to two hours 2025-11-13 20:23:14 -08:00
f9ed8f16ac Bind methods and log errors for missing keys 2025-11-13 20:08:15 -08:00
d6ad90ee7a Change SHUTTLE_WILL_ARRIVE_AT_STOP to return payload of last stop and current stop, to avoid data race 2025-11-13 18:52:13 -08:00
0cf2a4d2e7 Add logic to clear out previous ETAs 2025-11-13 18:35:20 -08:00
2fbc13202d Apply the same changes to the in-memory repository 2025-11-13 18:02:10 -08:00
bf6a2e4667 Add recursive implementation of cascading ETA updates for multiple ordered stops 2025-11-13 17:38:53 -08:00
91584fc2ab Extract fallback logic to a separate method 2025-11-13 17:19:37 -08:00
42c71815e6 Make the ETA assertion check for multiple ETA insertions 2025-11-13 17:13:43 -08:00
e4151ed914 Return stop3 as part of the test data 2025-11-13 17:08:47 -08:00
f95263eec8 Add another stop and ordered stop to the test data 2025-11-13 17:07:28 -08:00
b5690d8983 Also implement the fallback logic in the in-memory repository 2025-11-13 16:52:39 -08:00
076f1fd1e4 Extract setup and assertion procedure into method for all three tests to use 2025-11-13 16:49:32 -08:00
71ac18cb8d Add a similar test for previous hour 2025-11-13 16:43:53 -08:00
3f267be154 Add test for previous day fallback calculation 2025-11-13 16:41:51 -08:00
d302e8a2cb Reorganize self-updating repository tests based on method name 2025-11-13 16:07:42 -08:00
7463cb27f1 Add proof-of-concept which also queries same time period yesterday, as well as last hour 2025-11-11 21:41:32 -08:00
1866224b5b Prevent shuttle arriving at same stop from firing the SHUTTLE_WILL_ARRIVE_AT_STOP event 2025-11-11 21:22:39 -08:00
4096c0ce44 Add call to startListeningForUpdates() 2025-11-11 21:05:43 -08:00
97feec6d3f Connect etaRepository to Redis in the builder 2025-11-11 20:47:20 -08:00
e03e962ab9 Implement remaining logic for RedisExternalSourceETARepository 2025-11-11 20:24:55 -08:00
6a31609960 Implement in-memory equivalents of both ETA repositories 2025-11-11 20:21:40 -08:00
7c5aeda4a9 Add timeouts for event emitters, and update reference behavior for the ETA entry test 2025-11-11 20:11:45 -08:00
ac81806b26 Swap implementations of two handler methods, and add reference implementation for adding travel time data 2025-11-11 19:51:31 -08:00
88a393c1ad Update ETA add test to use setReferenceTime 2025-11-11 19:41:42 -08:00
5e8a3c84a6 Add previous reference implementation of getAverageTravelTimeSeconds and method bindings 2025-11-11 19:41:19 -08:00
4b37f3a121 Add a helper method for setting the "reference time" of the self-updating repo 2025-11-11 19:34:05 -08:00
e8f4e7b9ee Add a base protected method for adding ETAs and emitting events 2025-11-11 19:33:52 -08:00
9af50ddfe9 Change the event name to SHUTTLE_WILL_ARRIVE_AT_STOP, and emit it before the stop is updated 2025-11-11 19:10:13 -08:00
b9fefcc6a9 Implement BaseRedisETARepository based on past implementation 2025-11-11 19:05:47 -08:00
04e8354e05 Implement BaseInMemoryETARepository based on previous implementation in shuttle repository 2025-11-11 19:03:16 -08:00
bc7ba7d507 Subscribe to shuttle events and add stub methods 2025-11-11 18:56:46 -08:00
6cf61d1cdb Unify eventual getter implementations in a BaseRedisETARepository class 2025-11-11 18:47:13 -08:00
7497c1d004 Apply the same events to UnoptimizedInMemoryShuttleRepository 2025-11-11 18:39:24 -08:00
a3bfa4894e Fix date type mismatch for data comparison 2025-11-11 18:35:13 -08:00
992a2b6149 Implement the events 2025-11-11 18:34:17 -08:00
5b1c5897fc Re-apply event emitter tests for the new shuttle events 2025-11-11 16:53:27 -08:00
c6f8e5642e Fix typing errors in test 2025-11-11 15:44:34 -08:00
dac22da0b5 Update method call to addOrUpdateShuttle 2025-11-11 15:38:03 -08:00
2854912e07 Update ETA mock helper to take an ExternalSourceETARepository 2025-11-11 15:37:07 -08:00
1d366bd63d Pass new configuration option in apolloTestServerHelpers 2025-11-11 15:36:07 -08:00
fd59f6cbd9 Update resolvers to use the ETA repository 2025-11-11 15:30:52 -08:00
90041555a9 Remove ETA-related tests from ShuttleRepositorySharedTests 2025-11-11 15:30:43 -08:00
747477cff4 Update shuttle repository implementations to remove ETA functionality 2025-11-11 15:28:11 -08:00
5eb40ff8a5 Remove ETA-related methods from ShuttleGetterSetterRepository 2025-11-11 15:28:01 -08:00
15315f826e Add SHUTTLE_ARRIVED_AT_STOP event and remove ETA-related methods for ShuttleGetterRepository 2025-11-11 15:27:42 -08:00
42cd34e755 Update InterchangeSystem and caller to construct an ETA repository based on argumetns 2025-11-11 15:16:21 -08:00
57d0171d68 Remove unused integration test systems 2025-11-11 15:16:01 -08:00
6df4678acc Add stub for in-memory self updating ETA repository 2025-11-11 15:14:03 -08:00
75537a9c3a Update interface and implementation of ETANotificationScheduler
Rely on both the ETA repository and the shuttle repository
2025-11-11 14:59:36 -08:00
7a0e303080 Add stub for in-memory external source ETA repository 2025-11-11 14:59:24 -08:00
e05519f651 Remove ETA events and add shuttle events for ShuttleGetterRepository 2025-11-11 14:52:40 -08:00
99999450b5 Remove clearAllData from BaseRedisRepository and update tests 2025-11-11 14:50:53 -08:00
01c55d52ec Make BaseRedisRepository extend EventEmitter; make RedisShuttleRepository extend BaseRedisRepository 2025-11-11 12:31:32 -08:00
b6b79e1345 Migrate ETA-related tests over to the ETA repository tests 2025-11-11 12:27:10 -08:00
13bfc853e3 Add getAverageTravelTimeSeconds in SelfUpdatingETARepository definition 2025-11-11 12:26:45 -08:00
e0b00d1887 Extract setupRouteAndOrderedStops to a test helper 2025-11-11 12:21:26 -08:00
83e3414c8e Add the tests back, for directly adding/removing ETAs 2025-11-11 12:08:02 -08:00
ea943206c0 Remove spy on nonexistent method 2025-11-11 12:06:05 -08:00
cb23379723 Add shuttleRepository injection to the constructor of RedisSelfUpdatingETARepository 2025-11-11 11:41:39 -08:00
7ac8232878 Fix typings for BaseRedisRepository 2025-11-11 11:41:21 -08:00
e707c9cfd8 Have the ETA repositories depend on BaseRedisRepository instead of duplicating methods 2025-11-11 11:33:45 -08:00
809c0b9270 Add clearAllData as a declared method 2025-11-11 11:03:33 -08:00
c43201b867 Move RepositoryHolder into test helpers, as it's shared across shared tests 2025-11-11 10:59:20 -08:00
53e120cd1e Add stubs for Redis implementations of both ETA repository variants 2025-11-11 10:54:46 -08:00
1783d5f6f7 Define a set of interfaces for the ETA repository 2025-11-11 10:51:34 -08:00
41bea7b693 Remove unused code within ApiBasedShuttleRepositoryLoader 2025-11-10 20:26:44 -08:00
09f5fa55df Delete loadShuttleTestData 2025-11-10 20:23:54 -08:00
589f630f23 In test helpers, update method call for ETA update 2025-11-10 20:23:25 -08:00
5bcb1b3ccd Remove check for ETA data update 2025-11-10 20:23:07 -08:00
fb3be46ef2 Update shared test file and remove Redis-specific tests 2025-11-10 20:22:37 -08:00
ca5c7c618a Update function calls within tests where shuttle repositories are a dependency 2025-11-10 20:22:08 -08:00
20c97de94d Unify the new ETA functionality across all shuttle repositories 2025-11-10 20:20:39 -08:00
e6793572bf Remove call to addOrUpdateEta within the shuttle repository loader 2025-11-10 20:15:39 -08:00
4481496fda Privatize implementation details for updating shuttle ETA information 2025-11-10 19:51:15 -08:00
c9cbde7a78 Rename internal method to something more representative of if 2025-11-10 19:35:21 -08:00
a3854aab6d Update test to account for Redis unpredictability 2025-11-10 19:13:39 -08:00
864097154e Make addTravelTimeDataPoint public, add tests, and fix the query interval 2025-11-10 19:05:32 -08:00
7a0937c04e Refactor updateShuttleLastStopArrival to use shuttle ID 2025-11-10 18:23:44 -08:00
ef20117f3c Modify getShuttleLastStopArrival to use shuttle ID 2025-11-10 18:21:55 -08:00
91517669f0 Implement updateEtasBasedOnHistoricalData and add a test for it 2025-11-10 18:11:54 -08:00
d92db84738 Implement travel time data point saving and loading, and tests 2025-11-10 16:46:34 -08:00
bd1ae07662 Rename historical parking query arguments to HistoricalParkingAverageFilterArguments 2025-11-10 16:46:07 -08:00
c946647483 Update getArrivedStopIfExists and implement tests for it 2025-11-10 15:45:38 -08:00
584dd44aad Update Claude instructions for testing 2025-11-10 15:45:21 -08:00
cba91dae55 Implement getShuttleLastStopArrival and updateShuttleLastStopArrival 2025-11-10 15:36:33 -08:00
2a80a049bd Add updated test cases and update call to stop arrival helper 2025-11-10 15:09:13 -08:00
63ed267ded Add necessary call to updateShuttleLastTimeArrival 2025-11-10 14:54:48 -08:00
321631e6c9 Add partial implementation and scaffolding of historical ETA update 2025-11-10 14:49:41 -08:00
b535868897 Add a method to check whether a shuttle has arrived at a stop 2025-11-10 14:33:00 -08:00
8ed23544c5 Update InterchangeSystem builder to use Redis-based shuttle repository 2025-11-03 10:47:19 -08:00
4b4715cdb2 Add RedisShuttleRepository and ShuttleRepositorySharedTests, like pattern for parking data 2025-11-03 10:34:21 -08:00
72f596821a Update CLAUDE.md with new testing instructions 2025-11-03 10:31:37 -08:00
e29e772f6b Switch back to CLAUDE file 2025-11-03 10:18:13 -08:00
7300a808a7 Merge pull request #84 from brendan-ch/codex/implement-eventemitter-for-updates
codex/implement-eventemitter-for-updates
2025-10-21 16:39:54 -07:00
9a959d388f Update verification of mock listener if removed 2025-10-21 16:24:36 -07:00
91ac397c75 Reorganize tests based on functions 2025-10-21 16:22:05 -07:00
4db517d4c0 Ensure shuttle repository uses typed EventEmitter overrides 2025-10-10 19:56:19 -07:00
a2f074b150 Merge pull request #83 from brendan-ch/chore/sort-resolver-data
chore/sort-resolver-data
2025-09-30 15:33:59 -07:00
9040ed7175 Move CLAUDE.md to AGENTS.md 2025-09-30 15:30:34 -07:00
415b461308 Add sorting to all data types
- Add name-based sorting for entities with names
- Add order-based sorting for ordered stops
- Add "seconds remaining" based sorting for ETAs
- Add tests to check sorting
2025-09-30 15:24:22 -07:00
33b0de44fb Merge pull request #78 from brendan-ch/chore/implement-cleaning-of-passio-data 2025-09-26 17:08:50 -07:00
513c8bb127 Change the default maximum distance, and move it to environment.ts 2025-09-26 16:24:20 -07:00
dc50f98a6d Restore the pruning test for shuttles 2025-09-26 16:19:39 -07:00
67a4b3fc8e Update test cases to test updating shuttle data depending on proximity 2025-09-26 16:12:01 -07:00
155ab2b4d2 Filter out out-of-service shuttles 2025-09-26 16:01:32 -07:00
5ed15ee534 Update mock data arrangement of shuttle data test to match updated requirements 2025-09-26 15:56:27 -07:00
7ed780a459 Implement filtering of shuttles by proximity to the closest route polyline coordinate 2025-09-26 15:40:58 -07:00
9268ad5181 Repeat warning message for ETA data 2025-09-26 15:00:04 -07:00
7d886975b9 Update mocked methods in tests, and test method names to match 2025-09-26 14:56:49 -07:00
db263122a1 Refactor remaining methods in ApiBasedShuttleRepositoryLoader.ts to follow the same sub-procedure pattern 2025-09-26 14:56:34 -07:00
74a55b3f57 Refactor shuttle update method into clear sub-procedures 2025-09-26 14:45:11 -07:00
9a2b2f65b9 Rename methods to indicate a single responsibility 2025-09-26 14:31:20 -07:00
72 changed files with 4497 additions and 5655 deletions

View File

@@ -21,3 +21,4 @@ RATE_LIMIT_DELAY_MULTIPLIER_MS=
# Redis connection
REDIS_URL=
REDIS_USE_TLS=

60
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: "Deploy to birb co."
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
# See documentation: https://github.com/tailscale/github-action?tab=readme-ov-file
- name: Connect to Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_CLIENT_SECRET }}
tags: tag:ci
- name: Set up SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan ${{ vars.HOST }} >> ~/.ssh/known_hosts
- name: Remove directory from server
run: |
ssh -i ~/.ssh/id_ed25519 ${{ vars.USERNAME }}@${{ vars.HOST }} << 'EOF'
rm -rf ~/${{ vars.DIRECTORY_NAME }}
EOF
# Avoid needing to set up SSH access to GitHub for this user
- name: Transfer repository files to server
run: |
scp -i ~/.ssh/id_ed25519 -r ./* ${{ vars.USERNAME }}@${{ vars.HOST }}:~/${{ vars.DIRECTORY_NAME }}
- name: Deploy on server with Docker
run: |
ssh -i ~/.ssh/id_ed25519 ${{ vars.USERNAME }}@${{ vars.HOST }} << 'EOF'
cd ~/${{ vars.DIRECTORY_NAME }}
export APNS_IS_PRODUCTION=${{ secrets.APNS_IS_PRODUCTION }}
export APNS_BUNDLE_ID=${{ secrets.APNS_BUNDLE_ID }}
export APNS_TEAM_ID=${{ secrets.APNS_TEAM_ID }}
export APNS_KEY_ID=${{ secrets.APNS_KEY_ID }}
export APNS_PRIVATE_KEY=${{ secrets.APNS_PRIVATE_KEY }}
export PARKING_LOGGING_INTERVAL_MS=${{ secrets.PARKING_LOGGING_INTERVAL_MS }}
export RATE_LIMITS_DISABLED=${{ secrets.RATE_LIMITS_DISABLED }}
export RATE_LIMIT_WINDOW_MS=${{ secrets.RATE_LIMIT_WINDOW_MS }}
export RATE_LIMIT_DELAY_AFTER_REQUESTS=${{ secrets.RATE_LIMIT_DELAY_AFTER_REQUESTS }}
export RATE_LIMIT_DELAY_MULTIPLIER_MS=${{ secrets.RATE_LIMIT_DELAY_MULTIPLIER_MS }}
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
docker compose -f docker-compose.prod.yml down --remove-orphans
docker compose -f docker-compose.prod.yml up -d --build
EOF

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v22.15.0

112
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with code in this repository.
## Development Commands
@@ -12,90 +12,82 @@ docker compose run dev
# Run comprehensive test suite
docker compose run test
# Generate GraphQL TypeScript types
npm run generate
# Build for development
# Build for development (install, codegen, tsc)
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
```
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.
Project Inter Server is a GraphQL-based backend for college transit tracking with real-time shuttle data, parking availability, and push notifications.
### Core Components
- InterchangeSystem: Central orchestrator for shuttles, parking, and notifications
- Use `InterchangeSystem.build()` in production
- Use `InterchangeSystem.buildForTesting()` in tests
1. **InterchangeSystem** - Central orchestrator managing shuttles, parking, and notifications
- Use `InterchangeSystem.build()` for production
- Use `InterchangeSystem.buildForTesting()` for tests
- Repository Pattern (data access abstraction)
- Shuttle: `UnoptimizedInMemoryShuttleRepository`
- Parking: `InMemoryParkingRepository`
- Notifications: `RedisNotificationRepository` (prod) / `InMemoryNotificationRepository` (test)
2. **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
3. **Data Loaders** - External API integration
- `ApiBasedShuttleRepositoryLoader` - Passio GO! API integration
- `ChapmanApiBasedParkingRepositoryLoader` - University parking data
- `TimedApiBasedRepositoryLoader` - Periodic data refresh
- Notification System
- `ETANotificationScheduler` Shuttle arrival notifications
- `AppleNotificationSender` APNS integration
4. **Notification System**
- `ETANotificationScheduler` - Manages shuttle arrival notifications
- `AppleNotificationSender` - APNS integration
- Default threshold: 180 seconds
### GraphQL Schema
## GraphQL
- Schema definition: `schema.graphqls`
- Generated types: `src/generated/`
- Resolvers: `src/resolvers/`
- Combined in: `src/MergedResolvers.ts`
- 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
- `test/` - Comprehensive test suite with mock data
## 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
### 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 server with hot reload
- `test` Unit/integration tests
- `redis` Persistent Redis
- `redis-no-persistence` Ephemeral Redis for tests
### 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
## 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. 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.
- 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
- Use the name of the branch for all pull requests
- Name pull requests after their branch name.
### Code Style
- Prefer arrow functions, especially in classes
- 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.

4
Caddyfile Normal file
View File

@@ -0,0 +1,4 @@
interchange-api.bchen.dev {
reverse_proxy app:4000
}

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /usr/src/app
COPY . .
EXPOSE 4000

52
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,52 @@
# 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
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
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
redis_data:
caddy_data:
caddy_config:

View File

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

3
package-lock.json generated
View File

@@ -29,6 +29,9 @@
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=22.15.0"
}
},
"node_modules/@ampproject/remapping": {

View File

@@ -4,8 +4,8 @@
"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",
"generate": "graphql-codegen --config codegen.ts",
@@ -34,5 +34,8 @@
"graphql": "^16.10.0",
"jsonwebtoken": "^9.0.2",
"redis": "^4.7.0"
},
"engines": {
"node": ">=22.15.0"
}
}

View File

@@ -14,6 +14,11 @@ 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";
export interface InterchangeSystemBuilderArguments {
name: string;
@@ -32,6 +37,18 @@ 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;
}
export class InterchangeSystem {
@@ -40,6 +57,7 @@ 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,
@@ -55,28 +73,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,6 +91,7 @@ export class InterchangeSystem {
args.id,
timedShuttleDataLoader,
shuttleRepository,
etaRepository,
notificationScheduler,
notificationRepository,
timedParkingLoader,
@@ -91,49 +99,53 @@ export class InterchangeSystem {
);
}
/**
* 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 +173,58 @@ 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,
);
}
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 +247,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 };
}
}

View File

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

View 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);
});
});

View File

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

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

View File

@@ -23,6 +23,8 @@ const supportedSystems: InterchangeSystemBuilderArguments[] = [
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
name: "Chapman University",
useSelfUpdatingEtas: true,
shuttleStopArrivalDegreeDelta: 0.001,
}
]

View File

@@ -1,3 +1,3 @@
export interface RepositoryLoader {
fetchAndUpdateAll(): Promise<void>;
updateAll(): Promise<void>;
}

View File

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

View File

@@ -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) => {

View File

@@ -21,7 +21,7 @@ export class ChapmanApiBasedParkingRepositoryLoader implements ParkingRepository
this.fetchAndUpdateParkingStructures = this.fetchAndUpdateParkingStructures.bind(this);
}
async fetchAndUpdateAll() {
async updateAll() {
await this.fetchAndUpdateParkingStructures();
}

View File

@@ -34,7 +34,7 @@ describe("ChapmanApiBasedParkingRepositoryLoader", () => {
spy.mockResolvedValue(undefined);
});
await loader.fetchAndUpdateAll();
await loader.updateAll();
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalled();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]);
});
});
});

View 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);
}
}

View 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);
}
}

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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),

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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,8 @@ 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));
},
parkingSystem: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);

View File

@@ -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) {

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -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 = `

View File

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

View File

@@ -4,7 +4,6 @@ import {
generateMockRoutes,
generateMockShuttles,
generateMockStops,
generateParkingStructures
} from "../../../testHelpers/mockDataGenerators";
import {
addMockRouteToRepository,
@@ -62,6 +61,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 +106,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 +344,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));
});
});
});

View File

@@ -0,0 +1,5 @@
export interface RepositoryHolder<T> {
name: string;
factory(): Promise<T>;
teardown(): Promise<void>;
}

View File

@@ -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,13 @@ function setUpTestServer() {
});
}
const systemInfoForTesting = {
const systemInfoForTesting: InterchangeSystemBuilderArguments = {
id: "1",
name: "Chapman University",
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
useSelfUpdatingEtas: false,
shuttleStopArrivalDegreeDelta: 0.001,
};
export function buildSystemForTesting() {

View File

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

View 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
View 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__/*/**"]
}

View File

@@ -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__/*/**"]
}