194 Commits

Author SHA1 Message Date
506ac612bc Merge branch 'main' of ssh://gitea.bchen.dev:2222/brendan/project-inter-server
All checks were successful
Deploy to Coolify / deploy (push) Successful in 3m48s
2026-09-02 14:34:22 -07:00
30edc307af CLAUDE.md -> AGENTS.md 2026-09-02 14:34:16 -07:00
20adae3675 Delete .github/workflows/deploy.yml
Some checks failed
Deploy to Coolify / deploy (push) Has been cancelled
2026-09-02 21:21:42 +00:00
cd7d529b86 Rename the workflow
Some checks failed
Deploy to Coolify / deploy (push) Has been cancelled
2026-09-02 21:21:26 +00:00
15504ecba3 Add the build step to the Dockerfile
All checks were successful
Publish image / deploy (push) Successful in 3m47s
2026-09-02 13:49:27 -07:00
e98ef96b2e Update package.json so start no longer runs build
Some checks failed
Publish image / deploy (push) Has been cancelled
2026-09-02 20:40:40 +00:00
98eb1083eb Add image deployment workflow
Some checks failed
Publish image / deploy (push) Has been cancelled
2026-09-01 13:58:08 +00:00
3aa2a2583d Merge pull request #110 from brendan-ch/claude/apollo-router-migration-research-y8oVn
claude/apollo-router-migration-research-y8oVn
2026-03-27 18:35:47 -07:00
5b441b3698 Fix remaining npm audit issues 2026-03-27 18:15:33 -07:00
e45a94f260 Upgrade Apollo Server 4 to 5
@apollo/server v4 reached end-of-life on January 26, 2026. This bumps
to v5 (resolves to 5.5.0) and raises the graphql peer requirement to
^16.11.0 as required by AS5. No application code changes were needed
since the project already used the AS5-compatible patterns (separate
@as-integrations/express5 package, context in expressMiddleware options).

https://claude.ai/code/session_015DcGUmHQtHQRt2QUSefXga
2026-03-27 15:36:15 +00:00
c088f1003b Merge pull request #109 from brendan-ch/chore/remove-docker-host-in-deploy
chore/remove-docker-host-in-deploy
2026-03-24 16:13:42 -07:00
e0e4092218 Add Docker host and runtime directory exports 2026-03-24 16:12:28 -07:00
893274ca6a Remove DOCKER_HOST export in deploy.yml
Remove Docker host export from deployment script.
2026-03-24 16:08:12 -07:00
0601d9e6a0 Merge pull request #108 from brendan-ch/chore/remove-tailscale-for-deploy
chore/remove-tailscale-for-deploy
2026-03-24 16:01:33 -07:00
91f5f4ba7c Remove Tailscale connection from deploy workflow
Removed Tailscale connection step from deployment workflow.
2026-03-24 15:57:38 -07:00
4764ee6af0 Merge pull request #106 from brendan-ch/claude/add-initial-region-loading-jfNcI 2026-03-23 15:23:47 -07:00
44d2da45d1 Update Chapman region to wider campus bounds
https://claude.ai/code/session_0162emnCi1KxW5FkTNn2ZrvH
2026-03-23 22:08:57 +00:00
fe649c81eb Change region to use top-left and bottom-right coordinates
Replace center+delta region representation with a bounding box defined
by topLeft and bottomRight coordinate pairs, which maps more directly
to the visible map area.

https://claude.ai/code/session_0162emnCi1KxW5FkTNn2ZrvH
2026-03-23 22:05:57 +00:00
946f8dd342 Add initial region loading for supported systems
Expose an initialRegion field on the System GraphQL type so the app can
display the correct campus area on first load instead of a zoomed-out
default map view. Chapman University is configured with coordinates
centered on campus.

https://claude.ai/code/session_0162emnCi1KxW5FkTNn2ZrvH
2026-03-23 22:02:42 +00:00
690b2e1559 Merge pull request #105 from brendan-ch:chore/update-caddy-config
chore/update-caddy-config
2026-02-25 16:42:23 -08:00
78f20fa925 Remove the Caddyfile and expose port 4000 locally 2026-02-25 16:40:43 -08:00
90d993736d Merge pull request #104 from brendan-ch/chore/patch-deploy-workflow-1
chore/patch-deploy-workflow
2026-02-23 12:40:31 -08:00
4d79b842f5 Set Docker host for deployment workflow 2026-02-23 12:39:59 -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
74 changed files with 4923 additions and 6493 deletions

View File

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

47
.github/workflows/deploy-coolify.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: "Deploy to Coolify"
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
with:
submodules: recursive
- name: Login to Gitea Container Registry
uses: docker/login-action@v4
with:
registry: gitea.bchen.dev
username: ${{ github.actor }}
password: ${{ secrets.PAT }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: gitea.bchen.dev/${{ github.actor }}/project-inter-server:latest
- name: Set up Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
use-cache: false
tailscaled-args: --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055
- name: Deploy to Coolify
run: |
curl --request POST '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'

1
.nvmrc Normal file
View File

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

103
AGENTS.md Normal file
View File

@@ -0,0 +1,103 @@
# CLAUDE.md
This file provides guidance to all agents when working with code in this repository.
## Development Commands
### Core Development
```bash
# Start development server with hot reloading
docker compose run dev
# Run comprehensive test suite
docker compose run test
# Generate GraphQL TypeScript types
npm run generate
# Build for development (install, codegen, tsc)
npm run build:dev
```
Only use Docker Compose for running tests, and only use `docker compose run test`
to run tests; don't try to run tests for individual files.
## Architecture Overview
Project Inter Server is a GraphQL-based backend for college transit tracking with real-time shuttle data, parking availability, and push notifications.
- InterchangeSystem: Central orchestrator for shuttles, parking, and notifications
- Use `InterchangeSystem.build()` in production
- Use `InterchangeSystem.buildForTesting()` in tests
- Repository Pattern (data access abstraction)
- Shuttle: `UnoptimizedInMemoryShuttleRepository`
- Parking: `InMemoryParkingRepository`
- Notifications: `RedisNotificationRepository` (prod) / `InMemoryNotificationRepository` (test)
- Data Loaders (external API integration)
- `ApiBasedShuttleRepositoryLoader` Passio GO! API
- `ChapmanApiBasedParkingRepositoryLoader` Parking data
- `TimedApiBasedRepositoryLoader` Periodic refresh wrapper
- Notification System
- `ETANotificationScheduler` Shuttle arrival notifications
- `AppleNotificationSender` APNS integration
## GraphQL
- Schema definition: `schema.graphqls`
- Generated types: `src/generated/`
- Resolvers: `src/resolvers/`
- Resolver merge: `src/MergedResolvers.ts`
## Directory Structure
- `src/entities/` Core business logic
- `src/repositories/` Data access layer
- `src/loaders/` External API integrations
- `src/notifications/` Push notification system
- `src/resolvers/` GraphQL resolvers and tests
- `testHelpers/` Test utilities and mock data
## Docker Services
- `dev` Development server with hot reload
- `test` Unit/integration tests
- `redis` Persistent Redis
- `redis-no-persistence` Ephemeral Redis for tests
## Testing Patterns
- Prefer `buildForTesting()` to construct systems in tests.
- Mock external APIs using JSON snapshots under `testHelpers/jsonSnapshots`.
- Use in-memory repositories for speed where possible.
- When adding features that affect API output, add focused resolver tests in the corresponding `src/resolvers/__tests__` file.
- Separate unit tests from integration tests where practical to keep feedback fast and failures well-scoped.
## Development Guidelines
### General Guidelines
- Use test-driven development where possible. Write tests before implementation and run them before and after changes.
- Use Docker Compose for tests. Run `docker compose run test` so you can see full output.
### Git Workflow
- Name pull requests after their branch name.
### Code Style
- Prefer arrow functions, especially within classes.
- Keep changes minimal and focused; avoid unrelated refactors. Mention incidental issues separately.
- Respect existing interfaces and types; use non-destructive edits.
### Agent Tips
- Write or update tests alongside changes; validate with `docker compose run test`.
- When touching GraphQL resolvers, co-locate new tests in `src/resolvers/__tests__`.
## Multi-tenant Support
Currently supports Chapman University (Passio System ID: `263`). Each university system uses isolated repositories and configuration. New systems should be added via `InterchangeSystem` configuration and appropriate loaders.

102
CLAUDE.md
View File

@@ -1,101 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Core Development
```bash
# Start development server with hot reloading
docker compose run dev
# Run comprehensive test suite
docker compose run test
# Generate GraphQL TypeScript types
npm run generate
# Build for development
npm run build:dev
```
### Testing
```bash
# Run all tests
npm test
# Run specific test file
npm test -- --testPathPattern=<test-file-name>
# Run tests with coverage
npm test -- --coverage
```
## Architecture Overview
**Project Inter Server** is a GraphQL-based backend for college transit tracking with real-time shuttle data, parking availability, and push notifications.
### Core Components
1. **InterchangeSystem** - Central orchestrator managing shuttles, parking, and notifications
- Use `InterchangeSystem.build()` for production
- Use `InterchangeSystem.buildForTesting()` for tests
2. **Repository Pattern** - Data access abstraction
- Shuttle: `UnoptimizedInMemoryShuttleRepository`
- Parking: `InMemoryParkingRepository`
- Notifications: `RedisNotificationRepository` (prod) / `InMemoryNotificationRepository` (test)
3. **Data Loaders** - External API integration
- `ApiBasedShuttleRepositoryLoader` - Passio GO! API integration
- `ChapmanApiBasedParkingRepositoryLoader` - University parking data
- `TimedApiBasedRepositoryLoader` - Periodic data refresh
4. **Notification System**
- `ETANotificationScheduler` - Manages shuttle arrival notifications
- `AppleNotificationSender` - APNS integration
- Default threshold: 180 seconds
### GraphQL Schema
- Schema definition: `schema.graphqls`
- Generated types: `src/generated/`
- Resolvers: `src/resolvers/`
- Combined in: `src/MergedResolvers.ts`
### Directory Structure
- `src/entities/` - Core business logic
- `src/repositories/` - Data access layer
- `src/loaders/` - External API integrations
- `src/notifications/` - Push notification system
- `test/` - Comprehensive test suite with mock data
### Multi-tenant Support
Currently supports Chapman University (Passio System ID: "263"). Each university system has:
- System-specific configurations
- Isolated data repositories
- Custom API integrations
### Docker Services
- `dev` - Development with hot reload
- `test` - Unit/integration testing
- `redis` - Persistent Redis
- `redis-no-persistence` - Ephemeral Redis for tests
### Testing Patterns
- Use `buildForTesting()` for InterchangeSystem in tests
- Mock external APIs with JSON snapshots in test data
- Separate unit tests from integration tests
- Use in-memory repositories for faster testing
## Development Guidelines
### General Guidelines
- Use test-driven development. Always write tests before implementation, and run them before and after implementation.
- Use Docker Compose for tests. Make sure you run it in a way where you can actually see the test result.
### Git Workflow
- Use the name of the branch for all pull requests
### Code Style
- Prefer arrow functions, especially in classes
See AGENTS.md

View File

@@ -1,4 +1,7 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /usr/src/app
COPY . .
RUN npm run build
EXPOSE 4000
CMD ["npm", "run", "start"]

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

@@ -0,0 +1,42 @@
# self hosted server setup using Caddy
x-common-environment: &common-server-environment
APNS_IS_PRODUCTION: ${APNS_IS_PRODUCTION}
APNS_BUNDLE_ID: ${APNS_BUNDLE_ID}
APNS_TEAM_ID: ${APNS_TEAM_ID}
APNS_KEY_ID: ${APNS_KEY_ID}
APNS_PRIVATE_KEY: ${APNS_PRIVATE_KEY}
PARKING_LOGGING_INTERVAL_MS: ${PARKING_LOGGING_INTERVAL_MS}
REDIS_URL: redis://redis:6379
REDIS_USE_TLS: 0
RATE_LIMITS_DISABLED: ${RATE_LIMITS_DISABLED}
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS}
RATE_LIMIT_DELAY_AFTER_REQUESTS: ${RATE_LIMIT_DELAY_AFTER_REQUESTS}
RATE_LIMIT_DELAY_MULTIPLIER_MS: ${RATE_LIMIT_DELAY_MULTIPLIER_MS}
services:
app:
build: .
command: npm run start
restart: unless-stopped
ports:
- "4000:4000"
depends_on:
- redis
environment:
<<: *common-server-environment
NODE_ENV: production
redis:
image: redis/redis-stack:7.2.0-v17
container_name: redis-timeseries
restart: unless-stopped
volumes:
- redis_data:/data
- ./redis-stack.conf:/redis-stack.conf
command: redis-stack-server /redis-stack.conf
volumes:
redis_data:
caddy_data:
caddy_config:

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}

1124
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,10 @@
"description": "",
"main": "dist/index.js",
"scripts": {
"build:dev": "npm install --include=dev && npm run generate && tsc",
"build": "npm install --include=dev && npm run generate && tsc && npm prune --omit=dev",
"build:dev": "npm install --include=dev && npm run generate && tsc --project tsconfig.build.json",
"build": "npm install --include=dev && npm run generate && tsc --project tsconfig.build.json && npm prune --omit=dev",
"start:dev": "npm run build:dev && node ./dist/index.js",
"start": "npm run build && node ./dist/index.js",
"start": "node ./dist/index.js",
"generate": "graphql-codegen --config codegen.ts",
"test": "npm run build:dev && jest --runInBand"
},
@@ -26,13 +26,16 @@
},
"private": true,
"dependencies": {
"@apollo/server": "^4.11.2",
"@apollo/server": "^5.0.0",
"@as-integrations/express5": "^1.1.2",
"express": "^5.1.0",
"express-rate-limit": "^8.0.1",
"express-slow-down": "^3.0.0",
"graphql": "^16.10.0",
"graphql": "^16.11.0",
"jsonwebtoken": "^9.0.2",
"redis": "^4.7.0"
},
"engines": {
"node": ">=22.15.0"
}
}

View File

@@ -6,6 +6,7 @@ scalar DateTime
type System {
id: ID!
name: String!
initialRegion: Region
routes: [Route!]
route(id: ID): Route
stops: [Stop!]
@@ -16,6 +17,11 @@ type System {
parkingSystem: ParkingSystem
}
type Region {
topLeft: Coordinates!
bottomRight: Coordinates!
}
type ParkingSystem {
systemId: ID!
parkingStructures: [ParkingStructure!]

View File

@@ -14,6 +14,12 @@ import {
ParkingRepositoryLoaderBuilderArguments
} from "../loaders/parking/buildParkingRepositoryLoaderIfExists";
import { RedisParkingRepository } from "../repositories/parking/RedisParkingRepository";
import { ShuttleGetterRepository } from "../repositories/shuttle/ShuttleGetterRepository";
import { InMemoryExternalSourceETARepository } from "../repositories/shuttle/eta/InMemoryExternalSourceETARepository";
import { ETAGetterRepository } from "../repositories/shuttle/eta/ETAGetterRepository";
import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository";
import { BaseInMemoryETARepository } from "../repositories/shuttle/eta/BaseInMemoryETARepository";
import { IRegion } from "./SharedEntities";
export interface InterchangeSystemBuilderArguments {
name: string;
@@ -32,6 +38,24 @@ export interface InterchangeSystemBuilderArguments {
* ID for the parking repository ID in the codebase.
*/
parkingSystemId?: string;
/**
* Controls whether to self-calculate ETAs or use the external
* shuttle provider for them.
*/
useSelfUpdatingEtas: boolean;
/**
* The size of the threshold to detect when a shuttle has arrived
* at a stop, in latitude/longitude degrees.
*/
shuttleStopArrivalDegreeDelta: number;
/**
* The initial map region to display when the app first loads
* this system. Represents a center coordinate and span.
*/
initialRegion?: IRegion;
}
export class InterchangeSystem {
@@ -40,10 +64,12 @@ export class InterchangeSystem {
public id: string,
public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader,
public shuttleRepository: ShuttleGetterSetterRepository,
public etaRepository: ETAGetterRepository,
public notificationScheduler: ETANotificationScheduler,
public notificationRepository: NotificationRepository,
public parkingTimedDataLoader: TimedApiBasedRepositoryLoader | null,
public parkingRepository: ParkingGetterSetterRepository | null,
public initialRegion: IRegion | null,
) {
}
@@ -55,28 +81,17 @@ export class InterchangeSystem {
static async build(
args: InterchangeSystemBuilderArguments,
) {
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository();
const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository
);
const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader(
shuttleDataLoader,
);
await timedShuttleDataLoader.start();
const { shuttleRepository, timedShuttleDataLoader, etaRepository } = await InterchangeSystem.buildProductionShuttleLoaderAndRepositories(args);
timedShuttleDataLoader.start();
const notificationRepository = new RedisNotificationRepository();
await notificationRepository.connect();
const notificationScheduler = new ETANotificationScheduler(
const { notificationScheduler, notificationRepository } = await InterchangeSystem.buildNotificationSchedulerAndRepository(
etaRepository,
shuttleRepository,
notificationRepository,
new AppleNotificationSender(),
args.id,
args
);
notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = await this.buildRedisParkingLoaderAndRepository(args.parkingSystemId);
let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId);
timedParkingLoader?.start();
return new InterchangeSystem(
@@ -84,56 +99,62 @@ export class InterchangeSystem {
args.id,
timedShuttleDataLoader,
shuttleRepository,
etaRepository,
notificationScheduler,
notificationRepository,
timedParkingLoader,
parkingRepository,
args.initialRegion ?? null,
);
}
/**
* Construct an instance of the class where all composited
* classes are correctly linked, meant for unit tests, and server/app
* integration tests.
* @param args
*/
static buildForTesting(
args: InterchangeSystemBuilderArguments,
) {
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository();
const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository
private static async buildProductionShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) {
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(
args.shuttleStopArrivalDegreeDelta,
);
// Note that this loader should not be started,
// so the test data doesn't get overwritten
const timedShuttleLoader = new TimedApiBasedRepositoryLoader(
let etaRepository: BaseInMemoryETARepository;
let shuttleDataLoader: ApiBasedShuttleRepositoryLoader;
if (args.useSelfUpdatingEtas) {
etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository);
(etaRepository as InMemorySelfUpdatingETARepository).startListeningForUpdates();
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository,
);
} else {
etaRepository = new InMemoryExternalSourceETARepository();
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository,
etaRepository as InMemoryExternalSourceETARepository,
);
}
const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader(
shuttleDataLoader,
);
const notificationRepository = new InMemoryNotificationRepository();
return { shuttleRepository, etaRepository, timedShuttleDataLoader };
}
private static async buildNotificationSchedulerAndRepository(
etaRepository: ETAGetterRepository,
shuttleRepository: ShuttleGetterRepository,
args: InterchangeSystemBuilderArguments
) {
const notificationRepository = new RedisNotificationRepository();
await notificationRepository.connect();
const notificationScheduler = new ETANotificationScheduler(
etaRepository,
shuttleRepository,
notificationRepository,
new AppleNotificationSender(false),
args.id,
);
notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId);
// Timed parking loader is not started here
return new InterchangeSystem(
args.name,
args.id,
timedShuttleLoader,
shuttleRepository,
notificationScheduler,
notificationRepository,
timedParkingLoader,
parkingRepository,
new AppleNotificationSender(),
args.id
);
return { notificationScheduler, notificationRepository };
}
private static async buildRedisParkingLoaderAndRepository(id?: string) {
@@ -161,7 +182,59 @@ export class InterchangeSystem {
return { parkingRepository, timedParkingLoader };
}
private static buildInMemoryParkingLoaderAndRepository(id?: string) {
/**
* Construct an instance of the class where all composited
* classes are correctly linked, meant for unit tests, and server/app
* integration tests.
* @param args
*/
static buildForTesting(
args: InterchangeSystemBuilderArguments,
) {
const { shuttleRepository, timedShuttleLoader, etaRepository } = InterchangeSystem.buildTestingShuttleLoaderAndRepositories(args);
// Timed shuttle loader is not started here
const { notificationScheduler, notificationRepository } = InterchangeSystem.buildTestingNotificationSchedulerAndRepository(
etaRepository,
shuttleRepository,
args
);
notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = this.buildTestingParkingLoaderAndRepository(args.parkingSystemId);
// Timed parking loader is not started here
return new InterchangeSystem(
args.name,
args.id,
timedShuttleLoader,
shuttleRepository,
etaRepository,
notificationScheduler,
notificationRepository,
timedParkingLoader,
parkingRepository,
args.initialRegion ?? null,
);
}
private static buildTestingNotificationSchedulerAndRepository(
etaRepository: ETAGetterRepository,
shuttleRepository: UnoptimizedInMemoryShuttleRepository,
args: InterchangeSystemBuilderArguments
) {
const notificationRepository = new InMemoryNotificationRepository();
const notificationScheduler = new ETANotificationScheduler(
etaRepository,
shuttleRepository,
notificationRepository,
new AppleNotificationSender(false),
args.id
);
return { notificationScheduler, notificationRepository };
}
private static buildTestingParkingLoaderAndRepository(id?: string) {
if (id === undefined) {
return { parkingRepository: null, timedParkingLoader: null };
}
@@ -184,4 +257,39 @@ export class InterchangeSystem {
return { parkingRepository, timedParkingLoader };
}
private static buildTestingShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) {
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(
args.shuttleStopArrivalDegreeDelta,
);
let etaRepository: BaseInMemoryETARepository;
let shuttleDataLoader: ApiBasedShuttleRepositoryLoader;
if (args.useSelfUpdatingEtas) {
etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository);
(etaRepository as InMemorySelfUpdatingETARepository).startListeningForUpdates();
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository,
);
} else {
etaRepository = new InMemoryExternalSourceETARepository();
shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository,
etaRepository as InMemoryExternalSourceETARepository,
);
}
// Note that this loader should not be started,
// so the test data doesn't get overwritten
const timedShuttleLoader = new TimedApiBasedRepositoryLoader(
shuttleDataLoader
);
return { shuttleRepository, etaRepository, timedShuttleLoader };
}
}

View File

@@ -11,3 +11,8 @@ export interface ICoordinates {
longitude: number;
}
export interface IRegion {
topLeft: ICoordinates;
bottomRight: ICoordinates;
}

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,12 @@ const supportedSystems: InterchangeSystemBuilderArguments[] = [
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
name: "Chapman University",
useSelfUpdatingEtas: true,
shuttleStopArrivalDegreeDelta: 0.001,
initialRegion: {
topLeft: { latitude: 33.85733, longitude: -117.89553 },
bottomRight: { latitude: 33.73970, longitude: -117.81878 },
},
}
]

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,16 @@ export const SystemResolvers: Resolvers<ServerContext> = {
return [];
}
return await system.shuttleRepository.getShuttles();
const shuttles = await system.shuttleRepository.getShuttles();
return shuttles.slice().sort((a, b) => a.name.localeCompare(b.name));
},
initialRegion: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);
if (!system) {
return null;
}
return system.initialRegion;
},
parkingSystem: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);

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,
@@ -36,6 +35,37 @@ describe("SystemResolvers", () => {
});
}
describe("initialRegion", () => {
const query = `
query GetSystemInitialRegion($systemId: ID!) {
system(id: $systemId) {
initialRegion {
topLeft {
latitude
longitude
}
bottomRight {
latitude
longitude
}
}
}
}
`;
it("returns the initial region for the system", async () => {
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const initialRegion = (response.body.singleResult.data as any).system.initialRegion;
expect(initialRegion).toEqual({
topLeft: { latitude: 33.85733, longitude: -117.89553 },
bottomRight: { latitude: 33.7397, longitude: -117.81878 },
});
});
});
describe("routes", () => {
const query = `
query GetSystemRoutes($systemId: ID!) {
@@ -62,6 +92,23 @@ describe("SystemResolvers", () => {
const routes = (response.body.singleResult.data as any).system.routes;
expect(routes.length === expectedRoutes.length);
});
it("returns routes sorted by name", async () => {
const routes = generateMockRoutes();
// Insert in reverse name order to verify sorting
const reversed = [...routes].sort((a, b) => b.name.localeCompare(a.name));
await Promise.all(reversed.map(async (route) => {
route.systemId = mockSystem.id;
await context.systems[0].shuttleRepository.addOrUpdateRoute(route);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).system.routes.map((r: any) => r.name);
expect(names).toEqual([...routes].sort((a, b) => a.name.localeCompare(b.name)).map(r => r.name));
});
});
describe("stops", () => {
@@ -90,6 +137,22 @@ describe("SystemResolvers", () => {
const stops = (response.body.singleResult.data as any).system.stops;
expect(stops.length === expectedStops.length);
});
it("returns stops sorted by name", async () => {
const stops = generateMockStops();
const reversed = [...stops].sort((a, b) => b.name.localeCompare(a.name));
await Promise.all(reversed.map(async (stop) => {
stop.systemId = mockSystem.id;
await context.systems[0].shuttleRepository.addOrUpdateStop(stop);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).system.stops.map((s: any) => s.name);
expect(names).toEqual([...stops].sort((a, b) => a.name.localeCompare(b.name)).map(s => s.name));
});
});
describe("stop", () => {
@@ -312,6 +375,22 @@ describe("SystemResolvers", () => {
const shuttles = (response.body.singleResult.data as any).system.shuttles;
expect(shuttles.length === expectedShuttles.length);
});
it("returns shuttles sorted by name", async () => {
const shuttles = generateMockShuttles();
const reversed = [...shuttles].sort((a, b) => b.name.localeCompare(a.name));
await Promise.all(reversed.map(async (shuttle) => {
shuttle.systemId = mockSystem.id;
await context.systems[0].shuttleRepository.addOrUpdateShuttle(shuttle);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).system.shuttles.map((s: any) => s.name);
expect(names).toEqual([...shuttles].sort((a, b) => a.name.localeCompare(b.name)).map(s => s.name));
});
});
});

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,17 @@ function setUpTestServer() {
});
}
const systemInfoForTesting = {
const systemInfoForTesting: InterchangeSystemBuilderArguments = {
id: "1",
name: "Chapman University",
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
useSelfUpdatingEtas: false,
shuttleStopArrivalDegreeDelta: 0.001,
initialRegion: {
topLeft: { latitude: 33.85733, longitude: -117.89553 },
bottomRight: { latitude: 33.73970, longitude: -117.81878 },
},
};
export function buildSystemForTesting() {

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