686 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
c244a4b037 Merge pull request #77 from brendan-ch/chore/add-ordered-stops-to-route
chore/add-ordered-stops-to-route
2025-09-23 10:38:23 -07:00
e7b5f034f3 Add orderedStops property to the Route type 2025-09-23 10:15:42 -07:00
529f4e5869 Merge pull request #75 from brendan-ch/chore/dependency-updates
chore/dependency-updates
2025-09-13 18:44:15 -07:00
ca14fa6939 Update dependencies to latest minor/patch versions
- express-rate-limit: 8.0.1 → 8.1.0
- @types/node: 22.16.0 → 22.18.3
- ts-jest: 29.4.0 → 29.4.1
- typescript: 5.8.3 → 5.9.2

All tests pass (205 tests, 20 suites)
No security vulnerabilities found

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:34:41 -07:00
4830a449a1 Merge pull request #69 from brendan-ch/chore/improved-redis-handling
chore/improved-redis-handling
2025-09-01 12:40:53 -07:00
f49113e67b Document the REDIS_URL environment variable 2025-09-01 12:14:47 -07:00
72b77c3b60 Add a reconnection interval constant 2025-09-01 12:06:23 -07:00
051f6c12e7 Add error logging for unexpected Redis disconnections 2025-09-01 11:58:27 -07:00
6901f6d45b Merge pull request #68 from brendan-ch/chore/rate-limits
chore/rate-limits
2025-08-26 17:30:31 -07:00
5a183b632c Export the same variables from mocks 2025-08-26 17:12:27 -07:00
07ccd05734 Add environment variables to control settings 2025-08-26 17:09:25 -07:00
2c3d0a9fd4 Swap express-rate-limit for express-slow-down 2025-08-26 17:05:08 -07:00
8438d44ac3 Use the Express.js rate limiter middleware if not disabled 2025-08-26 11:34:29 -07:00
9856719e38 Change the server setup to use Express.js for hosting 2025-08-26 11:02:03 -07:00
8b8aae3ff9 Remove the app integration testing support 2025-08-26 10:49:46 -07:00
627cb87d34 Install express.js and express-rate-limit 2025-08-26 10:49:14 -07:00
125081f40b Configure docker-compose.yml and environment.ts to add rate limit toggle 2025-08-26 09:46:02 -07:00
5b9f0b9dea Merge pull request #67 from brendan-ch/chore/test-coverage-artifacts
chore/test-coverage-artifacts
2025-08-25 11:00:19 -07:00
798350caed Only run the test workflow on pull requests 2025-08-25 10:57:35 -07:00
eb8730de2a Update test workflow to upload coverage and post comment 2025-08-25 10:49:31 -07:00
3b5a554b2f Merge pull request #65 from brendan-ch/chore/update-dependencies
chore/update-dependencies
2025-08-13 14:23:26 -04:00
07b739cdde Fix security vulnerabilities 2025-08-13 13:58:53 -04:00
eeb09dca5b Merge pull request #64 from brendan-ch/chore/move-tests
chore/move-tests
2025-08-01 17:21:29 -07:00
7063f79647 Fix the critical security vulnerability 2025-08-01 17:19:33 -07:00
33927f940f Merge branch 'main' into chore/move-tests 2025-08-01 17:16:57 -07:00
b745d58ce0 Ensure that test and mock directories are excluded when compiling 2025-07-31 22:49:36 -04:00
14fbdc7408 Move testHelpers directory up one level 2025-07-31 22:41:05 -04:00
b7299b8359 Move all tests to subdirectories underneath code to be tested 2025-07-31 22:35:49 -04:00
5614fcae97 Merge pull request #63 from brendan-ch/feat/parking-statistics-data-in-graphql
feat/parking-statistics-data-in-graphql
2025-07-21 19:49:24 -04:00
0fd8de13f9 Add controls for minimum interval and maximum timespan to example .env 2025-07-21 19:46:31 -04:00
6444251649 Implement mocking of environment file 2025-07-21 19:42:28 -04:00
321e7a7fa9 Enable automatic reset of mock state 2025-07-21 19:41:45 -04:00
883dc9ef6e Create a src-level environment.ts file for all environment variables 2025-07-21 19:35:04 -04:00
b737cd0fa5 Add errors to the parking structure resolvers 2025-07-21 19:16:38 -04:00
2f46a0b07c Remove correctness test, to be reimplemented at a later time 2025-07-21 19:08:53 -04:00
cc03803a3c Change schema to accept a float for the average spots available 2025-07-21 19:08:22 -04:00
e78982538e Add implementation and tests for ParkingStructureResolvers.ts 2025-07-21 19:06:00 -04:00
ee7b5eefda Rename query interface for historical data 2025-07-19 13:14:39 -04:00
52a0133107 Add system ID to parking structure 2025-07-19 12:55:36 -04:00
09ee17874d Update test case naming for ParkingRepositorySharedTests.test.ts to clarify scope of test 2025-07-19 12:50:39 -04:00
0a5a71d78f Add basic test case for ParkingStructureResolvers 2025-07-19 12:50:27 -04:00
182587596c Change properties to match GraphQL input and query result 2025-07-19 12:15:41 -04:00
8ee1f1522e Change ParkingStructureCountOptions and HistoricalParkingAverageQueryResult to use Date objects
This matches the behavior of `updatedTime` on shuttle objects. When returning API data, dates are converted into milliseconds since Epoch by the DateTime scalar implementation.
2025-07-19 12:12:08 -04:00
ed037cf2d2 Move repositories into folders. 2025-07-19 11:58:45 -04:00
3302822bf8 Add a placeholder implementation of ParkingStructureResolvers 2025-07-19 11:55:51 -04:00
a76601d8cf Update schema with historical average data for parking structure. 2025-07-19 11:50:58 -04:00
a543f37f77 Merge pull request #61 from brendan-ch/chore/remove-integration-testing-mode
Remove support for integration testing mode
2025-07-18 17:00:33 -04:00
5486046c24 Remove support for integration testing mode
- Remove app-integration-test service from Docker Compose
- Remove references to integration testing mode from documentation
- Clean up CLAUDE.md to reflect current Docker services

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 16:53:29 -04:00
de3519f120 Merge pull request #59 from brendan-ch/chore/update-dependencies
chore/update-dependencies
2025-07-08 00:08:32 -04:00
e7488ee739 Update dependencies to latest compatible versions
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-08 00:05:21 -04:00
ba700cb698 Merge pull request #58 from brendan-ch/hotfix/use-tls
hotfix/use-tls
2025-07-03 16:37:22 -04:00
86710657df Use TLS in production 2025-07-03 16:30:12 -04:00
d27b36a969 Merge pull request #57 from brendan-ch/feat/time-series-database
feat/time-series-database
2025-07-03 16:05:44 -04:00
5db4a82535 Update historical average test to actually check the average value 2025-07-03 16:01:14 -04:00
d1b60772d8 Update RedisParkingRepository.ts to use Redis aggregation functions 2025-07-03 16:00:57 -04:00
f406e3e01d Add retention period for new time series 2025-07-03 15:53:06 -04:00
7c359dd775 Move the 200ms delay to before the add call
This ensures that a new timestamp is created after the initial call to `addOrUpdateParkingStructure`
2025-07-03 15:40:14 -04:00
5f982fe182 Add PARKING_LOGGING_INTERVAL_MS as an environment variable 2025-07-03 15:20:16 -04:00
df657a02f3 Build Redis parking repository instead of in-memory one 2025-07-03 15:20:07 -04:00
b9d5f7b3df Refactor parking repositories to use dedicated Constants file and smaller functions 2025-07-03 15:19:57 -04:00
7f453157ee Update timeout within test to avoid UPSERT error 2025-07-02 20:34:29 -04:00
53632fe470 Add BaseRedisRepository as parent class of RedisNotificationRepository.ts and RedisParkingRepository.ts 2025-07-02 20:00:44 -04:00
19336ce6ec Add setLoggingInterval method and implementation for tests and other purposes.
Let users control the logging interval of both parking repositories.
2025-07-02 19:48:16 -04:00
868a9f3b1d Add RedisParkingRepository.ts and convert existing tests to shared tests for both classes 2025-07-02 19:40:45 -04:00
ca2a66509b Implement getHistoricalAveragesOfParkingStructureCounts for InMemoryParkingRepository.ts, and add tests 2025-07-02 19:10:16 -04:00
8b5d80a9d6 Update test-driven development guideline for Claude 2025-07-02 19:02:28 -04:00
2b04ca01a9 Update InMemoryParkingRepository.ts and tests to use the circular queue 2025-07-02 18:48:33 -04:00
220b402b3b Optimize the append method to check if the data is already sorted 2025-07-02 18:43:41 -04:00
db506e8b1f Update CLAUDE.md with testing and code style instructions 2025-07-02 18:36:21 -04:00
731ac4bafc Make .idea an ignored folder 2025-07-02 18:35:35 -04:00
4fbc30a264 Add CircularQueue.ts and test file 2025-07-02 18:34:55 -04:00
18be03bfa6 Add stub implementation for my best friend, the humble circular queue
Add stubs for a custom circular queue implementation for the historical data in the parking repo
2025-07-01 22:01:07 -04:00
4758d566da Add constant MAX_NUM_ENTRIES and adjust PARKING_LOGGING_INTERVAL_MS 2025-07-01 21:47:15 -04:00
8fb296027d Fix remaining issues with tests, and add implementation within InMemoryParkingRepository.ts 2025-07-01 21:37:26 -04:00
95fa610d77 define behavior of historical data logging through tests 2025-07-01 21:32:26 -04:00
6759bba2ce Add properties to determine when the parking repository should log data 2025-07-01 21:23:15 -04:00
81272e94b1 Add getHistoricalAveragesOfParkingStructureCounts stub and update constructor with dependency injection support 2025-07-01 21:20:11 -04:00
1f252ee468 Define new method to get historical averages depending on timestamps and interval 2025-07-01 21:13:55 -04:00
ab29b08337 Add entities IParkingStructureTimestampRecord and type HistoricalParkingAggregatedQueryResult
To support historical data storage
2025-07-01 21:12:22 -04:00
6400929eb6 Update docker-compose.yml to use redis-stack instead of redis:alpine
This is the easiest way to set up the `timeseries` module during development
2025-07-01 19:01:33 -04:00
eca2ffe8ee rename redis.conf to redis-stack.conf 2025-07-01 19:00:53 -04:00
28890ef8c4 Merge pull request #55 from brendan-ch/chore/setup-claude
chore/setup-claude
2025-06-28 13:25:34 -07:00
721b66e6c1 Add CLAUDE.md with development commands and architecture guide
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-28 13:22:21 -07:00
aae24d9920 add updated shuttle 2025-06-27 08:17:32 -07:00
2db983ef53 Merge pull request #52 from brendan-ch/chore/update-test-data
add shuttle to test data
2025-06-26 20:58:51 -04:00
f6f71526d7 add shuttle to test data 2025-06-26 17:45:42 -07:00
2e5cb7e313 Merge pull request #47 from brendan-ch/chore/improve-apns-performance
[INT-75] chore/improve-apns-performance
2025-04-30 19:02:17 -07:00
f74376c8ed add test and code for client closure events 2025-04-30 18:17:55 -07:00
31e9c78ebd add test for closing connection on emitting closure events 2025-04-30 18:09:29 -07:00
4b77a89ac2 Merge branch 'main' into chore/improve-apns-performance 2025-04-30 17:54:00 -07:00
06d2a7bb27 extract code to open and close the apns connection 2025-04-30 17:51:28 -07:00
075b35e7ad reuse the client when sending a notification 2025-04-30 17:40:17 -07:00
9dcc17b687 add test to check that connection is reused 2025-04-30 17:33:54 -07:00
b068a69163 Merge pull request #46 from brendan-ch/feat/data-timestamps 2025-04-29 17:29:29 -07:00
a7c33c9479 empty commit to fix actions? 2025-04-29 17:21:17 -07:00
1c7ee9fbc3 change all millisecond references into Dates 2025-04-29 17:05:23 -07:00
b58babc7ea change updatedTime into a Date 2025-04-29 17:01:51 -07:00
f68dad0ca2 update parking system tests to use updatedTime parameter 2025-04-29 16:54:22 -07:00
b91f8ce151 update resolvers with updated argument name 2025-04-29 16:49:55 -07:00
c21446b80a update schema to use new DateTime scalar 2025-04-29 16:48:14 -07:00
ba82ce9f98 add custom DateTime scalar type 2025-04-29 16:47:08 -07:00
bdb1da0bc4 fix test due to mismatched timestamps on parking repo loader 2025-04-29 16:30:45 -07:00
b02e2f721d update resolvers to include timestamps 2025-04-29 16:26:47 -07:00
6dae7959bf update schema to include timestamp 2025-04-29 16:23:30 -07:00
ee4b11a308 remove "optional" from entity type 2025-04-29 16:19:41 -07:00
570ab8a178 update tests and mock data generators 2025-04-29 16:18:46 -07:00
518044d860 update parking code 2025-04-29 16:12:31 -07:00
563362b0b5 update ApiBasedShuttleRepositoryLoader with timestamps 2025-04-29 16:11:41 -07:00
d529554d84 update loadShuttleTestData for integration tests 2025-04-29 16:09:23 -07:00
fe90ffe911 update property name again and make it required 2025-04-29 16:07:30 -07:00
f1dd8650b6 update the time of the data update 2025-04-29 16:06:15 -07:00
6e03dc7fe3 Merge pull request #45 from brendan-ch/hotfix/update-schema-version-system
hotfix/update-schema-version-system
2025-04-24 12:09:26 -07:00
9969e93fc9 use min/max version instead of one static version 2025-04-24 09:56:36 -07:00
Brendan Chen
058b0d490f Merge pull request #44
hotfix/update-custom-keys-name
2025-04-23 17:18:01 -07:00
bf06c7ed3a update custom keys passed through notification scheduler 2025-04-23 11:55:15 -07:00
b3cd399e10 fix custom keys so they're spread across payload correctly 2025-04-23 11:54:32 -07:00
4798af9674 Merge pull request #43 from brendan-ch/feat/shuttle-id-notification-payload
[INT-53] feat/shuttle-id-notification-payload
2025-04-22 11:20:18 -07:00
12bdd4c631 add stop id, in case app wants to switch stops on notification tap 2025-04-22 11:17:10 -07:00
95f2e6ec29 add system id to the notification payload for good measure 2025-04-22 11:15:35 -07:00
1cb498b150 update custom keys to not be included in alert property 2025-04-22 11:10:00 -07:00
ba0375f582 add link to APNs documentation 2025-04-22 11:01:31 -07:00
44a0f56b93 add shuttleId as custom key in notification alert argument 2025-04-22 11:00:53 -07:00
26b1e70f5b Merge pull request #42 from brendan-ch/feat/graphql-parking-data
[INT-64] feat/graphql-parking-data
2025-04-16 17:30:40 -07:00
7e74748139 add integration test setup for parking data 2025-04-16 17:27:13 -07:00
02e440773a add functionality to normalize the spots available if over capacity 2025-04-16 17:18:48 -07:00
658222b91a inline variable 2025-04-16 17:06:24 -07:00
dd1a448160 display parking system in system resolver and remove unnecessary test 2025-04-16 17:06:09 -07:00
544cab5324 move parking structures to a sub-field of system 2025-04-16 17:01:41 -07:00
cb05d3ca03 add schema version endpoint 2025-04-16 16:55:00 -07:00
2092827d37 implement endpoints and update test 2025-04-16 16:52:45 -07:00
c9ecc18495 implement parkingStructure tests 2025-04-16 16:45:08 -07:00
a38ca3d3de add rest of parkingStructures tests 2025-04-16 16:39:07 -07:00
ea1150a98d add test for getting parking structures associated with id 2025-04-16 16:35:48 -07:00
37a67aace7 update system testing builder with parking system 2025-04-16 16:32:53 -07:00
1624d78cb5 add parking structures generator 2025-04-16 16:31:56 -07:00
a6d4b40fba add test cases for endpoints 2025-04-16 16:28:09 -07:00
2bace24623 add comment describing differentation between passio and interchange IDs 2025-04-16 16:28:00 -07:00
776ffad162 add parking system ID to Chapman supported system 2025-04-16 16:23:21 -07:00
267da3ff36 update InterchangeSystem build methods to attach parking system 2025-04-16 16:22:01 -07:00
6f6bab7058 Merge pull request #41 from brendan-ch/feat/parking-repository-loader
[INT-61] feat/parking-repository-loader
2025-04-11 17:20:09 -07:00
c9aa2c401f restructure parking and shuttle repository loaders 2025-04-11 17:13:39 -07:00
0e3c12bebc add test for ChapmanApiBasedParkingRepositoryLoader.fetchAndUpdateAll 2025-04-11 17:09:52 -07:00
50350d8c49 add test for ApiBasedShuttleRepositoryLoader.fetchAndUpdateAll 2025-04-11 17:08:20 -07:00
463f5cd582 make timeoutMs a read only property (set only in constructor) 2025-04-11 16:58:09 -07:00
44095e711a make timeoutMs a settable property 2025-04-11 16:57:47 -07:00
6cc20a4a6a remove redundant TODO 2025-04-11 16:53:45 -07:00
a0e0c19ca3 update timed loader to use class composition 2025-04-11 16:52:27 -07:00
d1a47baea6 implement fetchAndUpdateAll for parking repository loader 2025-04-11 16:44:54 -07:00
16dce32887 add a RepositoryLoader interface defining a fetchAndUpdateAll method 2025-04-11 16:43:46 -07:00
49b823890f remove "Timed" from class name to prepare for subclassing 2025-04-11 16:13:36 -07:00
c250e3ae5c bind fetchAndUpdateParkingStructures to class 2025-04-11 16:12:37 -07:00
2a01d007a9 refactor parking structure construction into separate method 2025-04-11 16:11:27 -07:00
6998ff529c implement method to retrieve parking structures 2025-04-10 16:59:09 -07:00
f67e3f13f5 update test data for correctness 2025-04-10 16:58:30 -07:00
e25a96f704 add test for ApiResponseError 2025-04-10 16:46:59 -07:00
b6856a98a2 move ApiResponseError assertion function to separate file 2025-04-10 16:46:20 -07:00
263607fc69 move ApiResponseError to separate file 2025-04-10 16:44:57 -07:00
3d6dc1e7b6 add pass to check validity of parking structures generated 2025-04-10 16:43:04 -07:00
f6d8253719 make generateId public 2025-04-10 16:40:54 -07:00
8c810c5224 add setup of loader 2025-04-10 16:36:28 -07:00
45df97f15c add methods to generate an ID based on address 2025-04-10 16:36:02 -07:00
aed005f7a7 add successful JSON response for chapman parking structure data 2025-04-10 16:29:13 -07:00
726af52685 rename test file 2025-04-10 16:27:15 -07:00
b9810057ce add test cases 2025-04-10 16:26:41 -07:00
110df5501d add chapman repository loader and buildParkingRepositoryLoaderIfExists function 2025-04-10 16:24:16 -07:00
dc639b1ca8 add parking repository loader interface 2025-04-10 16:15:30 -07:00
aa7b21c4c0 Merge pull request #40 from brendan-ch/feat/parking-data-repository
[INT-62] feat/parking-data-repository
2025-04-08 16:52:55 -07:00
461b1d1a59 implement class 2025-04-08 16:47:41 -07:00
fe2b8b2f69 add test cases 2025-04-08 16:45:58 -07:00
e5c4387cff add stub methods for repo 2025-04-08 16:39:23 -07:00
922fd7abb8 add repository methods 2025-04-08 16:37:36 -07:00
b67b537a08 Merge pull request #39 from brendan-ch/feat/parking-data-models
[INT-60] feat/parking-data-models
2025-04-08 16:32:52 -07:00
e5b686f0be fix import errors 2025-04-08 16:30:43 -07:00
78514c5b8a remove required parking structure and add stub resolvers 2025-04-08 16:28:21 -07:00
b2e6acfefa update graphql schema 2025-04-08 16:23:06 -07:00
be407fe220 add comment regarding future features 2025-04-08 16:20:39 -07:00
6782654564 add parking structure entity 2025-04-08 16:20:15 -07:00
6cd7d38bfd split out shared entities into separate file 2025-04-08 16:19:20 -07:00
0ce0c54ddd remove IPassioSystem entity 2025-04-08 16:09:15 -07:00
2741d229f9 rename entities.ts to ShuttleRepositoryEntities.ts 2025-04-08 16:07:48 -07:00
51c2aedef6 Merge pull request #38 from brendan-ch/chore/reorganize-classes-for-multiple-systems
[INT-58] chore/reorganize-classes-for-multiple-systems
2025-04-07 20:00:00 -07:00
c7666c092c update remaining tests 2025-04-07 19:55:25 -07:00
f0c754908d update incorrect argument in shuttle resolver 2025-04-07 19:19:10 -07:00
4f855a8225 update implementation of repository to get all entities 2025-04-07 19:18:30 -07:00
b6f3e4ccde update method signatures to not include system id 2025-04-07 19:17:14 -07:00
0076d987ca rename methods in getter repository 2025-04-07 19:06:18 -07:00
495a946e78 use the correct system ID for mock data in eta loader test 2025-04-07 13:59:11 -07:00
4f7d24acf0 add TODO to be resolved before merge 2025-04-07 13:57:14 -07:00
059a9f5067 fix ETA data not loading via repository loader 2025-04-07 13:56:26 -07:00
8e3765abfb update tests to correctly test internal system ID and passio ID 2025-04-07 13:47:50 -07:00
fe6db035f1 fix pruning and construction ID issues with repository loader 2025-04-07 13:47:36 -07:00
6767cc88a3 make clear that data loading for different integration test systems is a TODO 2025-04-07 13:32:45 -07:00
25f2a8c458 differentiate between passio system ID and internal ID in loader class 2025-04-07 13:26:19 -07:00
12f0a41153 update builder method and use in index 2025-04-07 13:19:43 -07:00
34765a0f3b update initialization of systems in index file 2025-04-07 13:02:13 -07:00
e4ff597385 swap shuttle data loaders for buildForTesting method 2025-04-07 13:00:24 -07:00
476b96c423 listen for notification updates in the builder methods 2025-04-07 12:59:04 -07:00
60256ce572 update integration test data to use interchange system builder arguments 2025-04-07 12:55:10 -07:00
554898b097 add another build method for use in development/production 2025-04-07 12:50:49 -07:00
96d4acb762 construct AppleNotificationSender to not send notifications 2025-04-07 12:49:06 -07:00
87dfd0dab1 rename build method to buildForTesting 2025-04-07 12:47:31 -07:00
0525ccc7e4 update system ID in ETA notification scheduler test 2025-04-07 12:42:34 -07:00
6e9478cd18 update parent system ID reference for ETA resolvers 2025-04-07 12:41:27 -07:00
bed2fbc7c3 update shuttle test data for integration tests 2025-04-07 12:41:04 -07:00
ea46115b86 add system ID to ETA object in graphql schema 2025-04-07 12:40:53 -07:00
c8ea822626 update query resolver tests to modify the context 2025-04-07 12:37:08 -07:00
a830dadbee use system ID provided on ordered stop to look for system 2025-04-07 12:31:16 -07:00
1e7082813d reference correct system which is rebuilt every test 2025-04-07 12:26:49 -07:00
d20711d53d update setup method to correctly create new system for testing every test 2025-04-07 12:26:18 -07:00
2ba64a9b0d attach the context correctly in system resolver tests 2025-04-07 12:18:26 -07:00
d950156161 use findSystemById function in query resolvers 2025-04-07 12:18:16 -07:00
3d13bad11c update mock data generators 2025-04-07 12:14:12 -07:00
329f21beb7 add systemId to ordered stop and route resolvers 2025-04-07 12:12:08 -07:00
973867bd6d update loader to add system ID to ordered stop and ETA 2025-04-07 12:11:36 -07:00
4b610c3ba9 add systemId to ordered stop and ETA entities 2025-04-07 12:10:00 -07:00
9c414be8ce fix test references after removal of addMockSystemToRepository 2025-04-07 12:07:32 -07:00
dbb56f207d add system ID field to ordered stop 2025-04-07 11:58:07 -07:00
c32dcb9610 remove async label from test server construction of context 2025-04-06 14:40:08 -07:00
d95d112bf2 define findSystemById method 2025-04-06 14:38:02 -07:00
f31e010828 update method to set up test server context 2025-04-06 12:56:12 -07:00
af57063b15 update mutation resolvers with temporary method to locate system 2025-04-06 12:54:22 -07:00
f113dc1ec2 update ETA resolvers 2025-04-06 12:44:23 -07:00
7136f9201d update ordered stop resolvers 2025-04-06 11:42:33 -07:00
1629f79299 update route resolvers 2025-04-06 11:38:46 -07:00
28c3e2f5f5 fix incorrect id passed into find system method for stop resolvers 2025-04-06 11:37:53 -07:00
8a600565dc update shuttle resolvers 2025-04-06 11:36:25 -07:00
8dd2f02783 add findSystemById method on server context 2025-04-06 11:34:37 -07:00
ea4723df85 update stop resolvers to look for system 2025-04-06 11:30:27 -07:00
472b3a0b05 add system ID as parent ID in system resolvers 2025-04-06 11:28:13 -07:00
bc598f0858 update graphql definition to include system ID in routes, stops, and shuttles 2025-04-06 11:26:58 -07:00
56466964c1 update method calls in TimedApiBasedShuttleRepositoryLoader 2025-04-06 11:23:27 -07:00
8c2fb3a52a use simplified implementation without system loading 2025-04-06 11:21:07 -07:00
95eb2c8f65 condense shuttle repository methods 2025-04-06 11:15:24 -07:00
9ff388f2d4 update system resolvers 2025-04-06 11:10:16 -07:00
ed2b7dbe5e update query resolvers to work with updated context 2025-04-06 11:03:41 -07:00
2d1e3c13d2 add an id string argument for the system 2025-04-06 10:58:58 -07:00
2d4bc6bdd4 name the name argument correctly 2025-04-06 10:55:03 -07:00
940d172e87 rename ISystem to IPassioSystem 2025-04-06 10:50:51 -07:00
6d762ce620 remove all system related data from shuttle repository 2025-04-06 10:49:47 -07:00
db097b00dc rename class to InterchangeSystem.ts and add builder arguments 2025-04-06 10:43:34 -07:00
2bc12aa794 update the system class 2025-04-06 10:34:30 -07:00
95410470f6 make shuttleRepository and notificationRepository accessible through system 2025-04-06 10:34:03 -07:00
5b695c7250 add notification repository argument 2025-04-06 10:29:41 -07:00
187800cf40 add a build method and export statement 2025-04-06 10:28:19 -07:00
d607a3742c update constructor of loaders to take a system id 2025-04-06 10:24:55 -07:00
4ea43ff21f add system entity as class (holder for shuttle repo and loader) 2025-04-06 10:19:33 -07:00
503bb0fec7 fix timed api loader tests 2025-04-06 10:16:58 -07:00
d584d1f58b update repository loader and tests to support only one system 2025-04-06 10:14:24 -07:00
5936c2b743 update tests to match new repository behavior 2025-04-06 09:57:03 -07:00
a144657fa8 update remove implementation and tests 2025-04-06 09:55:23 -07:00
695fec1fce rename update method and update return types 2025-04-06 09:51:13 -07:00
7e0c1f3539 rename get system method on shuttle repository, update tests 2025-04-06 09:48:55 -07:00
Brendan Chen
af61bd4726 Merge pull request #37 from brendan-ch/hotfix/redis-tls 2025-03-31 23:42:05 -07:00
d76740e5e7 add tls config 2025-03-31 23:37:28 -07:00
Brendan Chen
1b6792a8c7 Merge pull request #36 from brendan-ch/feat/persistent-data-storage-for-notifications 2025-03-31 22:52:09 -07:00
0e204af330 for development and production, swap out in memory repo for redis one 2025-03-31 20:37:53 -07:00
fbc08838df test unsubscribeFromNotificationChanges method 2025-03-31 20:34:45 -07:00
c6f846d109 add tests for isNotificationScheduled 2025-03-31 20:29:44 -07:00
50148cc2f4 implement RedisNotificationRepository 2025-03-31 20:26:26 -07:00
39066b88bc have teardown clear all data in redis before starting next test 2025-03-31 20:21:09 -07:00
9efd1d9289 set tests to run sequentially 2025-03-31 20:19:57 -07:00
372ecba952 restructure implementation holders into classes with teardown 2025-03-31 20:07:55 -07:00
3460f1becc update environment variables for testing and app-integration-testing 2025-03-31 20:07:40 -07:00
998643dc04 add call to connect method in factory 2025-03-31 19:55:56 -07:00
f34a2f27d7 add redis notification repository tests back for edge cases 2025-03-31 19:52:51 -07:00
7a5e1b8561 use describe.each to test the multiple implementations 2025-03-31 19:50:37 -07:00
a7ac9888f6 add setup for repository tests 2025-03-31 19:43:17 -07:00
36359a4caa add export and getter to check connection status 2025-03-31 19:42:47 -07:00
cc6623404c add connect/disconnect methods 2025-03-31 19:37:19 -07:00
c98367f12e add bindings and redis client arg for constructor 2025-03-31 19:35:38 -07:00
bef2ce18fb add redis types 2025-03-31 19:35:28 -07:00
1acd12d113 add stub methods for redis notification repository 2025-03-31 19:30:42 -07:00
a161a870d3 Merge pull request #35 from brendan-ch/feat/in-memory-notification-repository
feat/in-memory-notification-repository
2025-03-31 19:30:33 -07:00
c59ccd7f1a replace notifications repository with a fresh one in the test 2025-03-31 19:24:34 -07:00
a95c89c15b update tests and index with updated scheduler interface 2025-03-31 19:21:10 -07:00
ef94055133 add start and stop methods, move subscribe out of constructor 2025-03-31 19:17:41 -07:00
02b3b77a61 bind apple notification sender methods 2025-03-31 18:56:02 -07:00
75a4e133ed fix failing test for notification deletion 2025-03-27 11:40:32 -07:00
9c22e154be add bindings for the notifications repository 2025-03-27 11:34:01 -07:00
a84cedd05a add test and implementation for addOrUpdate listeners 2025-03-27 11:23:07 -07:00
51d66d8886 add tests and implementation for notification deletes 2025-03-27 11:18:43 -07:00
b0f04a9256 add stub methods for subscribe/unsubscribe 2025-03-27 11:10:49 -07:00
b2fb430a38 add notification event subscriber/unsubscriber 2025-03-27 11:08:40 -07:00
2b28b94dbd update eta notification scheduler test to use repository 2025-03-27 11:04:21 -07:00
f2a2dd74f6 listen to shuttle ETA updates in the scheduler constructor 2025-03-27 10:58:20 -07:00
ef94a9aa7e fix method calls and tests 2025-03-27 10:56:57 -07:00
a665c29745 add method to check if notification is scheduled 2025-03-27 10:46:38 -07:00
3761f43909 update server context to only include the notification repository 2025-03-27 10:42:43 -07:00
bda46d6808 rename repository to server repository in server context 2025-03-27 10:38:02 -07:00
bba00eb067 remove everything from the scheduler that's in the repository already 2025-03-27 10:30:12 -07:00
ae30660095 fix constructors 2025-03-27 10:23:30 -07:00
fb58414ba3 add notification repository as optional dependency 2025-03-27 10:20:25 -07:00
f007b72d94 add code for in-memory notification repository 2025-03-27 10:19:00 -07:00
ead401a3b1 add tests for in-memory notification repository 2025-03-27 10:11:41 -07:00
101c5ca6e0 change name of test for in-memory notification repository 2025-03-27 10:07:11 -07:00
7379840070 extract notification repository to interface 2025-03-27 10:06:42 -07:00
c517d93e3a add test cases and rename some methods 2025-03-27 10:02:13 -07:00
09be37cedb move arguments and notification interfaces to notification repository file 2025-03-27 09:57:49 -07:00
60b626b64f also rename the tests 2025-03-27 09:35:05 -07:00
687fe0d826 finish the rename for the data loaders 2025-03-27 09:33:37 -07:00
fab99db755 rename existing repository to shuttle repository 2025-03-27 09:32:29 -07:00
3a85f3da8b bind rest of notification scheduler methods to class 2025-03-27 09:28:47 -07:00
617f4dc72b add redis and run npm audit fix 2025-03-27 09:26:37 -07:00
7761e09b0e add separate redis service without persistence 2025-03-27 09:21:17 -07:00
8ef161ae3b update AOF option 2025-03-27 09:15:15 -07:00
f52b968d85 add redis configuration with AOF 2025-03-27 09:14:58 -07:00
5d0916981f Merge pull request #34 from brendan-ch/chore/setup-docker-and-redis
chore/setup-docker-and-redis
2025-03-27 08:47:08 -07:00
de4cfaaa8d update README 2025-03-27 08:46:53 -07:00
e880830e02 add redis URL to docker compose 2025-03-27 08:42:46 -07:00
cb65cd49f3 Revert "test that failing test breaks ci"
This reverts commit bef93538fb.
2025-03-27 08:30:23 -07:00
bef93538fb test that failing test breaks ci 2025-03-27 08:28:52 -07:00
83cf38ed91 remove dotenv 2025-03-27 08:27:14 -07:00
829b3f4ffd load each environment variable automatically from the env file 2025-03-27 08:26:44 -07:00
25f9dd62b1 remove .env file from testing and app-integration-test flows 2025-03-26 16:06:00 -07:00
e12a9817ff remove extra - 2025-03-26 16:00:15 -07:00
299bcfcf18 update docker command for compose build 2025-03-26 16:00:03 -07:00
b442cdda21 add app integration testing workflow 2025-03-26 15:55:24 -07:00
892ca7ec63 update github actions to use docker 2025-03-26 15:53:46 -07:00
4990e65c3e add testing workflow and update package.json 2025-03-26 15:53:39 -07:00
66642e7050 simplify the dockerfile and move it to a separate file 2025-03-26 15:50:47 -07:00
f2a464d52d fix the inline dockerfile to use cmd instead of run, and update port 2025-03-26 15:45:26 -07:00
2dd91c9885 add docker compose file and dockerignore 2025-03-26 15:31:12 -07:00
9942796f26 Merge pull request #33 from brendan-ch/feat/flexible-timing-support-for-notifications
[INT-27] feat/flexible-timing-support-for-notifications
2025-03-25 16:26:08 -07:00
7199adf20e play the default sound when the notification is sent 2025-03-25 16:21:19 -07:00
4d74027b0e update schema types for backwards compatibility with current schema 2025-03-25 16:16:18 -07:00
717575e004 update query resolvers to add the seconds threshold 2025-03-25 16:00:13 -07:00
99672e749f use seconds threshold provided in argument inputs 2025-03-25 15:48:42 -07:00
96e7e0297b update tests for mutation resolvers 2025-03-25 15:43:00 -07:00
14a3738fba add a public method to get the seconds threshold for a scheduled notification 2025-03-25 15:39:17 -07:00
1321ff1347 update graphql schema with updated arguments 2025-03-25 15:29:31 -07:00
fb49577cf6 add logic to send notification based on a provided threshold 2025-03-25 15:19:09 -07:00
1f8d4bfbd4 extract tuple.toString to variable 2025-03-25 15:11:47 -07:00
50636e5582 change set of device IDs to object of device IDs to seconds threshold 2025-03-25 15:09:32 -07:00
07b80f2012 update tests to include the secondsThreshold argument 2025-03-25 14:59:03 -07:00
8874704acb add optional secondsThreshold value 2025-03-25 14:55:34 -07:00
9e6e20503d Merge pull request #32 from brendan-ch/chore/split-notification-service
[INT-45] chore/split-notification-service
2025-03-24 10:35:38 -07:00
7e3305247d update notification scheduler tests with mock notification sender 2025-03-24 10:20:59 -07:00
2ccdba3d11 add test for failing error code 2025-03-24 10:14:43 -07:00
20282c3cf9 add test for APNs bundle ID 2025-03-24 10:13:58 -07:00
ac65e23984 add test for making a connection to the server 2025-03-24 10:11:17 -07:00
e433662f19 add test cases for sendNotificationImmediately 2025-03-24 10:09:37 -07:00
5670efb042 move getAPNsFullUrlToUse method to the notification sender tests 2025-03-24 10:05:10 -07:00
6251f0e247 move shouldSendNotifications property to the AppleNotificationSender 2025-03-24 10:02:30 -07:00
a6ceeb4dfb add optional dependency inject for apple notification sender 2025-03-24 09:59:29 -07:00
26cdab9907 copy over test for APNs token reload 2025-03-24 09:48:35 -07:00
23dc4c3f59 add getter for the last refreshed token time 2025-03-24 09:44:50 -07:00
9869632a88 change reloadAPNsTokenIfTimePassed method to public for testing 2025-03-24 09:42:41 -07:00
7f1bf005c1 integrate notification sender class into ETA notification scheduler 2025-03-24 09:39:07 -07:00
83766c90c5 add notification sending logic 2025-03-24 09:34:53 -07:00
a58780a37d add apns token methods 2025-03-24 09:30:22 -07:00
619ef9a27f update naming of classes and tests 2025-03-24 09:20:10 -07:00
Brendan Chen
eaa23d635c Merge pull request #31
chore/update-integration-testing-data
2025-03-20 20:23:53 -07:00
049ea731a3 add message for integration testing 2025-03-20 20:22:58 -07:00
d8ec54919e move shuttle away from the stop 2025-03-20 20:22:50 -07:00
135a294e5d Merge pull request #30 from brendan-ch/feat/orientation-data
feat/orientation-data
2025-03-17 13:30:32 -07:00
889e302912 add orientation data to api based loader 2025-03-17 13:25:46 -07:00
891bf522ba add orientation data to route resolver 2025-03-17 13:25:37 -07:00
024a7dd30c add orientation to loadTestData 2025-03-17 13:24:08 -07:00
919832e86f move orientation to shuttle instead of eta (whoops) 2025-03-17 13:23:53 -07:00
c0b7d99e8e add orientation data for mock shuttles function 2025-03-17 13:18:34 -07:00
d9d11e1b31 update entities and schema to include orientation in degrees 2025-03-17 13:15:07 -07:00
217fb065cb Merge pull request #26 from brendan-ch/chore/setup-mock-data-loader
chore/setup-mock-data-loader
2025-02-19 12:03:42 -08:00
371fb25faf add etas for a second shuttle 2025-02-19 12:01:26 -08:00
e8d5dbe6b3 link schmid gate to shuttle 2025-02-19 11:59:00 -08:00
ad07a6be44 add schmid gate as a stop 2025-02-19 11:58:13 -08:00
614b062b01 add polyline coordinates for the teal route 2025-02-19 11:53:31 -08:00
bc2f90e11e add polyline coordinates for red route 2025-02-19 11:30:38 -08:00
58bb8a1c32 change flag to integration-testing and update notification service construction 2025-02-19 11:15:11 -08:00
f1b9fb6824 add a flag to control whether the notification service should actually send notifications 2025-02-19 11:14:48 -08:00
92537a298b check for command line argument 2025-02-19 11:02:21 -08:00
63a215ac41 remove TODO for test data class 2025-02-19 10:55:37 -08:00
556906d15e remove test data repository loader 2025-02-19 10:55:16 -08:00
6da782fd3d add stubs for test data repository loader 2025-02-19 10:50:04 -08:00
3b173aeb22 add RepositoryLoader interface 2025-02-19 10:47:58 -08:00
11871da802 Merge pull request #24 from brendan-ch/fix-issue-template
Rename generic-task to generic-task.md
2025-02-16 14:22:53 -08:00
11934da0af Rename generic-task to generic-task.md 2025-02-16 14:21:46 -08:00
8794ced2dc Merge pull request #23 from brendan-ch/add-generic-task-template
Create generic task template
2025-02-16 13:52:51 -08:00
d8091bf148 Create generic task template 2025-02-16 13:51:43 -08:00
5c49221783 Merge pull request #22 from brendan-ch/add-issue-templates
Add issue templates
2025-02-16 13:46:09 -08:00
fdc62563b2 copy issue templates from app repo 2025-02-16 13:44:49 -08:00
ed2f2d42f3 Merge pull request #21 from brendan-ch/feat/notifications-state
feat/notifications-state
2025-02-12 20:04:01 -08:00
f5d40ebd7a add implementation for isNotificationScheduled 2025-02-12 19:53:26 -08:00
e22537d93d add second test for false condition 2025-02-12 19:52:20 -08:00
8971e3514d add isNotificationScheduled resolver and test cases 2025-02-12 19:51:42 -08:00
e697fd89b8 add isNotificationScheduled resolver for Query type 2025-02-12 19:45:16 -08:00
c09d35b65c implement method in NotificationService 2025-02-12 19:43:05 -08:00
c1ec50db7b add TupleKey method to convert string key back to tuple 2025-02-12 19:39:37 -08:00
bb77aca4ed add second test (base case) 2025-02-12 19:34:19 -08:00
b4f1f1b87d add rest of first test 2025-02-12 19:33:55 -08:00
420682dbb8 add arrange portion of first test 2025-02-12 19:32:46 -08:00
3ce72d79be add NotificationService method and test case stub 2025-02-12 19:30:11 -08:00
19b08d1c05 Merge pull request #20 from brendan-ch/hotfix/update-apns-key-for-deployment
hotfix/update-apns-key-for-deployment
2025-02-11 12:18:24 -08:00
2d4e206739 lower seconds threshold for notification to fire 2025-02-11 12:05:27 -08:00
a74a6c995a update implementation and test for getAPNsFullUrlToUse 2025-02-11 11:58:13 -08:00
bf1290bbc8 update environment with APNS_IS_PRODUCTION key 2025-02-11 11:56:53 -08:00
d80df1de23 add key to example env 2025-02-11 11:47:25 -08:00
ebcd35b85d use base64 encoded private key for apns 2025-02-11 11:46:46 -08:00
Brendan Chen
9af48be746 Merge pull request #19 from brendan-ch/hotfix/notification-fixes 2025-02-10 14:14:12 -08:00
6d1a85c2b4 use 403 status for non-successful mock 2025-02-10 14:02:49 -08:00
4fdf60f9bf update import for testing 2025-02-10 14:02:40 -08:00
764f6e35f0 add http2 mock and update tests 2025-02-10 13:56:42 -08:00
b5954e251d mock node:http2 module in test 2025-02-10 13:46:07 -08:00
a880d71c87 use updated getAPNs... method 2025-02-10 13:34:48 -08:00
5e186c643c propagate error message down to outer catch statement 2025-02-10 13:32:26 -08:00
baa94eeef5 update tests and implementation ofr getAPNsFullUrlToUse 2025-02-10 13:30:29 -08:00
da224c36ed use node.js http2 module instead of fetch 2025-02-10 13:21:48 -08:00
2d92370254 reload apns token on startup 2025-02-10 13:21:41 -08:00
6635a1a89b use api.development.push instead of api.sandbox.push 2025-02-10 10:36:49 -08:00
a6138b37cb use seconds instead of ms for claims payload 2025-02-10 10:32:46 -08:00
7e764502a0 add private folder to gitignore for p8 key 2025-02-10 10:32:30 -08:00
a3efae9f24 add dotenv support 2025-02-10 10:32:18 -08:00
4351c6d454 Merge pull request #18 from brendan-ch/feat/notification-service-integration
feat/notification-service-integration
2025-02-04 11:45:17 -08:00
d3c824be68 implement cancelNotification 2025-02-04 11:42:02 -08:00
4c4bd0fc4a swap toStrictEqual to toEqual for cancelNotification test 2025-02-04 11:41:57 -08:00
2b0bef876c implement scheduleNotification 2025-02-04 11:40:09 -08:00
fa40d15f5a change toStrictEqual to toEqual for notification assertion 2025-02-04 11:40:02 -08:00
e667e89556 add test for if notification doesn't exist on repository 2025-02-04 11:35:24 -08:00
71698da173 fix assertion for nested graphql response 2025-02-04 11:33:48 -08:00
acc4d08716 add a test for cancelling notification 2025-02-04 11:32:33 -08:00
d7b15812f3 make responses mandatory for mutations 2025-02-04 11:30:26 -08:00
1627bc39fe move duplicate code to method 2025-02-04 11:27:34 -08:00
69e2748eec add additional check for notification service scheduling 2025-02-04 11:25:31 -08:00
94c15f93dd add same test for stop resolvers + extract apollo request to function 2025-02-04 11:24:37 -08:00
cea7b48323 add mutation resolvers to merged resolvers 2025-02-04 11:22:17 -08:00
3a51d15e63 fix the resolver result 2025-02-04 11:21:54 -08:00
5687f7f600 add second test if shuttle ID doesn't exist in repository 2025-02-04 11:19:58 -08:00
0b9759aff1 update test cases and implement add notification test 2025-02-04 11:18:05 -08:00
8ac71090e4 make input mandatory 2025-02-04 11:01:27 -08:00
dccee459c3 add note on setupTestServerContext 2025-02-04 10:56:36 -08:00
b918bf7a67 update test helpers to consolidate ServerContext creation into one method 2025-02-04 10:56:00 -08:00
814f2c6584 change repository type to GetterSetterRepository to account for mutations 2025-02-04 10:52:07 -08:00
c79a3eac73 change method signatures for repository helpers to GetterSetterResponse interface 2025-02-04 10:51:41 -08:00
43f0ef27fc fix typo 2025-02-04 10:43:33 -08:00
f1f1d5c971 remove cancelAllNotifications resolver 2025-02-04 10:42:12 -08:00
d54539e65b add test cases 2025-02-04 10:41:35 -08:00
588d433fa3 add placeholder responses to get typescript to stop screaming 2025-02-04 10:36:46 -08:00
2116387203 add stubs for mutations 2025-02-04 10:35:50 -08:00
d730937b29 add notification support in schema 2025-02-04 10:27:06 -08:00
82a70ea04e add notification service to server context 2025-02-04 10:12:19 -08:00
Brendan Chen
6c31346d65 Merge pull request #17
feat/notification-service
2025-02-03 23:58:24 -08:00
9048d2f1bc remove todo for now 2025-02-03 23:54:37 -08:00
4a63929cc1 remove cancelAllNotifications method (one less method to test 2025-02-03 23:50:00 -08:00
629fe4dddd add cancelNotificationIfExists implementation 2025-02-03 23:48:53 -08:00
403bec7c63 rename method to define what happens if notification doesn't exist 2025-02-03 23:45:55 -08:00
d1db9b3742 add test for cancelNotification 2025-02-03 23:45:30 -08:00
3e4f6e5440 remove empty test file 2025-02-03 23:29:45 -08:00
80e976752b change behavior for missing shuttle/stop/ETA 2025-02-03 23:29:01 -08:00
6473661607 add tests for getAPNsFullUrlToUse 2025-02-03 23:26:16 -08:00
aff82a8185 fix binding errors with static method 2025-02-03 23:26:04 -08:00
c593fe14e1 make getAPNsFullUrlToUse public and testable 2025-02-03 23:23:21 -08:00
602ccf8139 update tests to wait for the publisher event to be sent 2025-02-03 23:20:42 -08:00
b3b4b71e22 extract seconds number to public readonly class property 2025-02-03 23:15:05 -08:00
11ea0518fe extract notification/eta data into function 2025-02-03 23:13:27 -08:00
0cd7c2fe47 add export for scheduled notification data 2025-02-03 23:13:13 -08:00
179fa07d36 make polling function a global function 2025-02-03 23:07:55 -08:00
b252adb5d4 use waitForCondition function to poll for data update 2025-02-03 23:06:28 -08:00
64931e7546 remove redundant comments 2025-02-03 23:00:18 -08:00
4c8658a3d9 bind all other methods to class 2025-02-03 22:52:35 -08:00
16d2d4b4ef add seconds threshold for notification 2025-02-03 22:47:17 -08:00
d357af7509 add shuttle and stop to repository within test 2025-02-03 22:42:50 -08:00
103ddef523 add handling for error and successful return 2025-02-03 22:42:25 -08:00
c5c0208371 add request code 2025-02-03 22:16:53 -08:00
f602aa6c2c add method to determine which url to use 2025-02-03 22:03:28 -08:00
18402401e3 rename token variable for clarity 2025-02-03 21:57:42 -08:00
339148c890 implement test which checks if notification stays scheduled 2025-02-03 21:55:26 -08:00
711caa1d15 add a second notification to schedule for the test 2025-02-03 21:52:01 -08:00
90e5fa200e add todo 2025-02-03 21:51:52 -08:00
8bc71d25fe add code to update device ids object on successful delivery 2025-02-03 21:50:59 -08:00
e96316aa1e add eta subscriber callback code 2025-02-03 21:37:20 -08:00
fba021d921 Merge branch 'main' into feat/notification-service 2025-02-03 21:31:46 -08:00
3bdc730c95 add the code to associate tuple key with array of device ids 2025-02-03 21:31:18 -08:00
69525a643d add another test to see if the data actually gets scheduled 2025-02-03 21:26:59 -08:00
737a61fd41 add signature and default return for isNotificationScheduled 2025-02-03 21:25:18 -08:00
e9c2c8a7bc add private object to store scheduled notifications 2025-02-02 21:33:23 -08:00
1e82ec2697 remove encryption key 2025-02-02 21:31:40 -08:00
11fe22ceea add method to check if notification is scheduled (for testing 2025-02-02 21:31:24 -08:00
fe233dfcbd add pseudocode 2025-02-02 21:24:40 -08:00
6172a04427 add documentation and change interface for cancelAllNotifications 2025-02-02 21:09:07 -08:00
f8ff7f9cc9 add testing logic for scheduleNotification test 2025-02-02 15:13:13 -08:00
dbbb9c0169 Merge pull request #16 from brendan-ch/feat/repository-eta-subscriber
feat/repository-eta-subscriber
2025-02-02 13:20:00 -08:00
7d762f17dc optimize imports 2025-02-02 13:18:33 -08:00
fe16bc7124 add test case for nonexistent callback 2025-02-02 13:16:28 -08:00
b30e4be895 rename methods for clarity on method events 2025-02-02 13:14:45 -08:00
9dd6e945c2 extract publishing code to private method 2025-02-02 13:14:04 -08:00
a810a2e78f add 'publish' code to addOrUpdateEta 2025-02-02 13:13:32 -08:00
01cbad3a73 add code to add/remove from subscriber array 2025-02-02 13:12:20 -08:00
047ff3a56e add test cases for the new functions 2025-02-02 13:09:54 -08:00
ec62b0c8bf update interface to not be async 2025-02-02 13:07:42 -08:00
899b953be5 add test cases 2025-02-02 13:05:13 -08:00
5957010a7f add test cases and documentation for functions 2025-02-02 13:04:03 -08:00
345d4905fe add method stubs in basic in-memory repository 2025-02-02 12:51:25 -08:00
acfc91d3c1 add method stubs for subscribing/unsubscribing to changes 2025-02-02 12:49:41 -08:00
138 changed files with 11719 additions and 5340 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore

View File

@@ -1,4 +1,24 @@
# Set to "1" for true
APNS_IS_PRODUCTION=
APNS_KEY_ID=
APNS_TEAM_ID=
APNS_BUNDLE_ID=
APNS_KEY_PATH=
# base64-encoded APNs private key
APNS_PRIVATE_KEY=
# control parking data logging
PARKING_LOGGING_INTERVAL_MS=
PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL=
PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN=
# control rate limiting features
RATE_LIMITS_DISABLED=
RATE_LIMIT_WINDOW_MS=
RATE_LIMIT_DELAY_AFTER_REQUESTS=
RATE_LIMIT_DELAY_MULTIPLIER_MS=
# Redis connection
REDIS_URL=
REDIS_USE_TLS=

12
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@@ -0,0 +1,12 @@
---
name: Bug report
about: Describe a bug within the app.
title: ''
labels: bug
assignees: ''
---
## Bug description
A clear and concise description of what the bug is, including how to reproduce it plus relevant screenshots.

20
.github/ISSUE_TEMPLATE/enhancement.md vendored Normal file
View File

@@ -0,0 +1,20 @@
---
name: Enhancement
about: New feature or enhancement for the project.
title: ''
labels: enhancement
assignees: ''
---
## User stories
- As a user...
## Tasks
- [ ]
## Documentation
-

16
.github/ISSUE_TEMPLATE/generic-task.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Generic task
about: Task with multiple steps.
title: ''
labels:
assignees: ''
---
## Tasks
- [ ]
## Documentation
-

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

View File

@@ -1,27 +1,31 @@
name: Node.js Test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
- name: Build and test with Docker Compose
run: docker compose run test
- name: Upload test coverage
id: coverage-upload
uses: actions/upload-artifact@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm run build:dev
- run: npm run test
name: code-coverage-report
path: coverage
- name: Post comment linking to artifact
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr comment ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--body "Test coverage artifact: ${{ steps.coverage-upload.outputs.artifact-url }}"

6
.gitignore vendored
View File

@@ -20,3 +20,9 @@ yarn-error.log*
# Testing
/coverage/
# Keys
private/
# JetBrains
.idea

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ExceptionCaughtLocallyJS" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/project-inter-server.iml" filepath="$PROJECT_DIR$/.idea/project-inter-server.iml" />
</modules>
</component>
</project>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

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.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
See AGENTS.md

7
Dockerfile Normal file
View File

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

View File

@@ -1,17 +1,19 @@
# Interchange Node.js Server
# Interchange Server
This is the server codebase for Interchange, an app for college
transit.
This is the server codebase for Interchange, an app for college transit.
## Setup
You'll need Node.js 20.x installed to run this project.
Clone this repository and run the following:
You'll need Docker + Compose installed to run this project.
Clone this repository and run one of the following:
```bash
$ npm start:dev
# run the standard Node development server and Redis
$ docker compose run dev
# run with unit/server integration tests
$ docker compose run test
# run with test data suitable for app integration tests
$ docker compose run app-integration-tests
```
This will run `npm install`, generate GraphQL type definitions,
and start the server in developer mode.

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:

64
docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
# Note that .env file will automatically populate
# variables in this file.
# If these variables are also set on the host system,
# those will be used over the .env file.
# See https://vsupalov.com/docker-arg-env-variable-guide/
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:
dev:
build: .
command: npm run start:dev
ports:
- "4000:4000"
depends_on:
- redis
environment:
<<: *common-server-environment
volumes:
- .:/usr/src/app
test:
build: .
command: npm run test
depends_on:
- redis-no-persistence
environment:
REDIS_URL: redis://redis-no-persistence:6379
RATE_LIMITS_DISABLED: 1
volumes:
- .:/usr/src/app
redis:
image: redis/redis-stack:7.2.0-v17
container_name: redis-timeseries
ports:
- "6379:6379"
volumes:
- redis_data:/data
- ./redis-stack.conf:/redis-stack.conf
command: redis-stack-server /redis-stack.conf
redis-no-persistence:
image: redis/redis-stack:7.2.0-v17
container_name: redis-timeseries-no-persistence
ports:
- "6379:6379"
volumes:
redis_data: # Add this volume definition

View File

@@ -110,7 +110,7 @@ const config = {
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
resetMocks: true,
// Reset the module registry before running each individual test
// resetModules: false,
@@ -119,7 +119,7 @@ const config = {
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
restoreMocks: true,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,

4279
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,28 +4,38 @@
"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": "jest"
"test": "npm run build:dev && jest --runInBand"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.3",
"@graphql-codegen/typescript": "4.1.2",
"@graphql-codegen/typescript-resolvers": "4.4.1",
"@jest/globals": "^29.7.0",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.10.2",
"@types/redis": "^4.0.11",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2",
"@types/jsonwebtoken": "^9.0.8"
"typescript": "^5.7.2"
},
"private": true,
"dependencies": {
"@apollo/server": "^4.11.2",
"graphql": "^16.10.0",
"jsonwebtoken": "^9.0.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.11.0",
"jsonwebtoken": "^9.0.2",
"redis": "^4.7.0"
},
"engines": {
"node": ">=22.15.0"
}
}

28
redis-stack.conf Normal file
View File

@@ -0,0 +1,28 @@
# See https://raw.githubusercontent.com/redis/redis/unstable/redis.conf
# for a full example
############################## APPEND ONLY MODE ###############################
# By default Redis asynchronously dumps the dataset on disk. This mode is
# good enough in many applications, but an issue with the Redis process or
# a power outage may result into a few minutes of writes lost (depending on
# the configured save points).
#
# The Append Only File is an alternative persistence mode that provides
# much better durability. For instance using the default data fsync policy
# (see later in the config file) Redis can lose just one second of writes in a
# dramatic event like a server power outage, or a single write if something
# wrong with the Redis process itself happens, but the operating system is
# still running correctly.
#
# AOF and RDB persistence can be enabled at the same time without problems.
# If the AOF is enabled on startup Redis will load the AOF, that is the file
# with the better durability guarantees.
#
# Note that changing this value in a config file of an existing database and
# restarting the server can lead to data loss. A conversion needs to be done
# by setting it via CONFIG command on a live server first.
#
# Please check https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/ for more information.
appendonly yes

View File

@@ -1,26 +1,68 @@
type Query {
systems: [System!]!
system(id: ID): System
}
scalar DateTime
# The Interchange system schema.
# Note how Passio ID and parking ID are abstracted away
# from the endpoints.
type System {
id: ID!
name: String!
initialRegion: Region
routes: [Route!]
route(id: ID): Route
stops: [Stop!]
stop(id: ID): Stop
shuttles: [Shuttle!]
shuttle(id: ID): Shuttle
parkingSystem: ParkingSystem
}
type Region {
topLeft: Coordinates!
bottomRight: Coordinates!
}
type ParkingSystem {
systemId: ID!
parkingStructures: [ParkingStructure!]
parkingStructure(id: ID): ParkingStructure
}
type ParkingStructure {
id: ID!
name: String!
capacity: Int!
spotsAvailable: Int!
coordinates: Coordinates!
address: String!
updatedTime: DateTime
systemId: ID!
historicalAverages(input: HistoricalParkingAverageQueryInput): [HistoricalParkingAverageQueryResult!]
}
type HistoricalParkingAverageQueryResult {
from: DateTime!
to: DateTime!
averageSpotsAvailable: Float!
}
input HistoricalParkingAverageQueryInput {
from: DateTime!
to: DateTime!
intervalMs: Int!
}
type Route {
name: String!
id: ID!
systemId: ID!
orderedStop(forStopId: ID): OrderedStop
orderedStops: [OrderedStop]
shuttles: [Shuttle!]
polylineCoordinates: [Coordinates!]!
color: String!
updatedTime: DateTime
}
type OrderedStop {
@@ -30,14 +72,18 @@ type OrderedStop {
routeId: ID!
stop: Stop
stopId: ID!
systemId: ID!
updatedTime: DateTime
}
type Stop {
id: ID!
systemId: ID!
name: String!
coordinates: Coordinates!
etas: [ETA!]
orderedStops: [OrderedStop!]
updatedTime: DateTime
}
type Coordinates {
@@ -51,14 +97,57 @@ type ETA {
shuttle: Shuttle
shuttleId: ID!
secondsRemaining: Float!
systemId: ID!
updatedTime: DateTime
}
type Shuttle {
name: String!
systemId: ID!
id: ID!,
coordinates: Coordinates!
route: Route
routeId: ID!
etas: [ETA!]
eta(forStopId: ID): ETA
orientationInDegrees: Float!
updatedTime: DateTime
}
# Queries
type Query {
systems: [System!]!
system(id: ID): System
isNotificationScheduled(input: NotificationInput!): Boolean
secondsThresholdForNotification(input: NotificationInput!): Int
schemaMinVersion: Int!
schemaCurrentVersion: Int!
}
# Mutations
type Mutation {
scheduleNotification(input: NotificationInput!): NotificationResponse!
cancelNotification(input: NotificationInput!): NotificationResponse!
}
input NotificationInput {
deviceId: ID!
shuttleId: ID!
stopId: ID!
secondsThreshold: Int
}
type NotificationResponse {
success: Boolean!
message: String!
data: Notification
}
type Notification {
deviceId: ID!
shuttleId: ID!
stopId: ID!
secondsThreshold: Int
}

View File

@@ -1,4 +1,4 @@
import { Coordinates, Eta, OrderedStop, Resolvers } from "./generated/graphql";
import { Resolvers } from "./generated/graphql";
import { ServerContext } from "./ServerContext";
import { QueryResolvers } from "./resolvers/QueryResolvers";
import { SystemResolvers } from "./resolvers/SystemResolvers";
@@ -7,13 +7,21 @@ import { OrderedStopResolvers } from "./resolvers/OrderedStopResolvers";
import { StopResolvers } from "./resolvers/StopResolvers";
import { ShuttleResolvers } from "./resolvers/ShuttleResolvers";
import { RouteResolvers } from "./resolvers/RouteResolvers";
import { MutationResolvers } from "./resolvers/MutationResolvers";
import { ParkingSystemResolvers } from "./resolvers/ParkingSystemResolvers";
import { DateTime } from "./scalars/DateTime";
import { ParkingStructureResolvers } from "./resolvers/ParkingStructureResolvers";
export const MergedResolvers: Resolvers<ServerContext> = {
...QueryResolvers,
...ParkingSystemResolvers,
...SystemResolvers,
...RouteResolvers,
...ShuttleResolvers,
...StopResolvers,
...OrderedStopResolvers,
...EtaResolvers,
};
...MutationResolvers,
...ParkingStructureResolvers,
DateTime: DateTime,
};

View File

@@ -1,5 +1,6 @@
import { GetterRepository } from "./repositories/GetterRepository";
import { InterchangeSystem } from "./entities/InterchangeSystem";
export interface ServerContext {
repository: GetterRepository;
}
systems: InterchangeSystem[];
findSystemById: (id: string) => InterchangeSystem | null;
}

View File

@@ -0,0 +1,11 @@
export const PARKING_LOGGING_INTERVAL_MS = 10000;
export const PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL = 1000;
export const PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN = 60000 * 60 * 24;
export const RATE_LIMITS_DISABLED = true;
export const RATE_LIMIT_WINDOW_MS = 10000;
export const RATE_LIMIT_DELAY_AFTER_REQUESTS = 10000;
export const RATE_LIMIT_DELAY_MULTIPLIER_MS = 1000;

View File

@@ -0,0 +1,295 @@
import { ETANotificationScheduler } from "../notifications/schedulers/ETANotificationScheduler";
import { TimedApiBasedRepositoryLoader } from "../loaders/TimedApiBasedRepositoryLoader";
import { UnoptimizedInMemoryShuttleRepository } from "../repositories/shuttle/UnoptimizedInMemoryShuttleRepository";
import { RedisNotificationRepository } from "../repositories/notifications/RedisNotificationRepository";
import { NotificationRepository } from "../repositories/notifications/NotificationRepository";
import { ShuttleGetterSetterRepository } from "../repositories/shuttle/ShuttleGetterSetterRepository";
import { InMemoryNotificationRepository } from "../repositories/notifications/InMemoryNotificationRepository";
import { AppleNotificationSender } from "../notifications/senders/AppleNotificationSender";
import { ApiBasedShuttleRepositoryLoader } from "../loaders/shuttle/ApiBasedShuttleRepositoryLoader";
import { ParkingGetterSetterRepository } from "../repositories/parking/ParkingGetterSetterRepository";
import { InMemoryParkingRepository } from "../repositories/parking/InMemoryParkingRepository";
import {
buildParkingRepositoryLoaderIfExists,
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;
/**
* ID to identify the system internally and in the API.
*/
id: string;
/**
* ID for fetching shuttle data from the Passio GO! system.
*/
passioSystemId: string;
/**
* 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 {
private constructor(
public name: string,
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,
) {
}
/**
* Construct an instance of the class where all composited
* classes are correctly linked, meant for use in development and production.
* @param args
*/
static async build(
args: InterchangeSystemBuilderArguments,
) {
const { shuttleRepository, timedShuttleDataLoader, etaRepository } = await InterchangeSystem.buildProductionShuttleLoaderAndRepositories(args);
timedShuttleDataLoader.start();
const { notificationScheduler, notificationRepository } = await InterchangeSystem.buildNotificationSchedulerAndRepository(
etaRepository,
shuttleRepository,
args
);
notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId);
timedParkingLoader?.start();
return new InterchangeSystem(
args.name,
args.id,
timedShuttleDataLoader,
shuttleRepository,
etaRepository,
notificationScheduler,
notificationRepository,
timedParkingLoader,
parkingRepository,
args.initialRegion ?? null,
);
}
private static async buildProductionShuttleLoaderAndRepositories(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,
);
}
const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader(
shuttleDataLoader,
);
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(),
args.id
);
return { notificationScheduler, notificationRepository };
}
private static async buildRedisParkingLoaderAndRepository(id?: string) {
if (id === undefined) {
return { parkingRepository: null, timedParkingLoader: null };
}
let parkingRepository: RedisParkingRepository | null = new RedisParkingRepository();
await parkingRepository.connect();
const loaderBuilderArguments: ParkingRepositoryLoaderBuilderArguments = {
id,
repository: parkingRepository,
};
let parkingLoader = buildParkingRepositoryLoaderIfExists(
loaderBuilderArguments,
);
let timedParkingLoader = null;
if (parkingLoader == null) {
parkingRepository = null;
} else {
timedParkingLoader = new TimedApiBasedRepositoryLoader(parkingLoader);
}
return { parkingRepository, timedParkingLoader };
}
/**
* 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 };
}
let parkingRepository: ParkingGetterSetterRepository | null = new InMemoryParkingRepository();
const loaderBuilderArguments: ParkingRepositoryLoaderBuilderArguments = {
id,
repository: parkingRepository,
};
let parkingLoader = buildParkingRepositoryLoaderIfExists(
loaderBuilderArguments,
);
let timedParkingLoader = null;
if (parkingLoader == null) {
parkingRepository = null;
} else {
timedParkingLoader = new TimedApiBasedRepositoryLoader(parkingLoader);
}
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

@@ -0,0 +1,17 @@
import { ICoordinates, IEntityWithId, IEntityWithTimestamp } from "./SharedEntities";
export interface IParkingStructure extends IEntityWithTimestamp, IEntityWithId {
address: string;
capacity: number;
spotsAvailable: number;
coordinates: ICoordinates;
name: string;
}
export interface IParkingStructureTimestampRecord {
timestampMs: Date;
id: string;
spotsAvailable: number;
}
// In the future, add support for viewing different levels of the structure

View File

@@ -0,0 +1,18 @@
export interface IEntityWithTimestamp {
updatedTime: Date;
}
export interface IEntityWithId {
id: string;
}
export interface ICoordinates {
latitude: number;
longitude: number;
}
export interface IRegion {
topLeft: ICoordinates;
bottomRight: ICoordinates;
}

View File

@@ -0,0 +1,55 @@
import { ICoordinates, IEntityWithId, IEntityWithTimestamp } from "./SharedEntities";
export interface IRoute extends IEntityWithId, IEntityWithTimestamp {
name: string;
color: string;
polylineCoordinates: ICoordinates[];
systemId: string;
}
export interface IStop extends IEntityWithId, IEntityWithTimestamp {
name: string;
systemId: string;
coordinates: ICoordinates;
}
export interface IShuttle extends IEntityWithId, IEntityWithTimestamp {
coordinates: ICoordinates;
name: string;
routeId: string;
systemId: string;
orientationInDegrees: number;
}
export interface IEta extends IEntityWithTimestamp {
secondsRemaining: number;
shuttleId: string;
stopId: string;
systemId: string;
}
export interface IOrderedStop extends IEntityWithTimestamp {
nextStop?: IOrderedStop;
previousStop?: IOrderedStop;
routeId: string;
stopId: string;
position: number;
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

@@ -1,51 +0,0 @@
export interface IEntityWithOptionalTimestamp {
millisecondsSinceEpoch?: number;
}
export interface IEntityWithId {
id: string;
}
export interface ISystem extends IEntityWithId, IEntityWithOptionalTimestamp {
name: string;
}
export interface ICoordinates {
latitude: number;
longitude: number;
}
export interface IRoute extends IEntityWithId, IEntityWithOptionalTimestamp {
name: string;
color: string;
polylineCoordinates: ICoordinates[];
systemId: string;
}
export interface IStop extends IEntityWithId, IEntityWithOptionalTimestamp {
name: string;
systemId: string;
coordinates: ICoordinates;
}
export interface IShuttle extends IEntityWithId, IEntityWithOptionalTimestamp {
coordinates: ICoordinates;
name: string;
routeId: string;
systemId: string;
}
export interface IEta extends IEntityWithOptionalTimestamp {
secondsRemaining: number;
shuttleId: string;
stopId: string;
}
export interface IOrderedStop extends IEntityWithOptionalTimestamp {
nextStop?: IOrderedStop;
previousStop?: IOrderedStop;
routeId: string;
stopId: string;
position: number;
}

26
src/environment.ts Normal file
View File

@@ -0,0 +1,26 @@
export const PARKING_LOGGING_INTERVAL_MS = process.env.PARKING_LOGGING_INTERVAL_MS
? parseInt(process.env.PARKING_LOGGING_INTERVAL_MS)
: 600000; // Every 10 minutes
export const PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL = process.env.PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL
? parseInt(process.env.PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL)
: 60000 * 60 * 2;
export const PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN = process.env.PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN
? parseInt(process.env.PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN)
: 60000 * 60 * 24;
export const RATE_LIMITS_DISABLED = process.env.RATE_LIMITS_DISABLED === "1";
export const RATE_LIMIT_WINDOW_MS = process.env.RATE_LIMIT_WINDOW_MS
? parseInt(process.env.RATE_LIMIT_WINDOW_MS)
: 10000;
export const RATE_LIMIT_DELAY_AFTER_REQUESTS = process.env.RATE_LIMIT_DELAY_AFTER_REQUESTS
? parseInt(process.env.RATE_LIMIT_DELAY_AFTER_REQUESTS)
: 10000;
export const RATE_LIMIT_DELAY_MULTIPLIER_MS = process.env.RATE_LIMIT_DELAY_MULTIPLIER_MS
? parseInt(process.env.RATE_LIMIT_DELAY_MULTIPLIER_MS)
: 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

@@ -1,41 +1,95 @@
import { readFileSync } from "fs";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { MergedResolvers } from "./MergedResolvers";
import { ServerContext } from "./ServerContext";
import { UnoptimizedInMemoryRepository } from "./repositories/UnoptimizedInMemoryRepository";
import { TimedApiBasedRepositoryLoader } from "./loaders/TimedApiBasedRepositoryLoader";
import { InterchangeSystem, InterchangeSystemBuilderArguments } from "./entities/InterchangeSystem";
import { ChapmanApiBasedParkingRepositoryLoader } from "./loaders/parking/ChapmanApiBasedParkingRepositoryLoader";
import express from "express";
import { expressMiddleware } from "@as-integrations/express5";
import {
RATE_LIMIT_DELAY_AFTER_REQUESTS,
RATE_LIMIT_DELAY_MULTIPLIER_MS,
RATE_LIMIT_WINDOW_MS,
RATE_LIMITS_DISABLED
} from "./environment";
import slowDown from "express-slow-down";
const typeDefs = readFileSync("./schema.graphqls", "utf8");
// In the future this can be stored as a separate file
const supportedSystems: InterchangeSystemBuilderArguments[] = [
{
id: "1",
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 },
},
}
]
async function main() {
const server = new ApolloServer<ServerContext>({
typeDefs,
resolvers: MergedResolvers,
introspection: process.env.NODE_ENV !== "production",
});
await server.start();
const repository = new UnoptimizedInMemoryRepository();
// await loadTestData(repository);
let systems: InterchangeSystem[];
const repositoryDataUpdater = new TimedApiBasedRepositoryLoader(
repository
);
await repositoryDataUpdater.start();
const { url } = await startStandaloneServer(server, {
listen: {
port: process.env.PORT ? parseInt(process.env.PORT) : 4000,
systems = await Promise.all(supportedSystems.map(
async (systemArguments) => {
return await InterchangeSystem.build(systemArguments);
},
context: async ({ req, res }) => {
));
const app = express();
const options = {
context: async () => {
return {
repository,
systems,
findSystemById: (id: string) => {
const system = systems.find((system) => system.id === id);
if (!system) {
return null;
}
return system;
},
}
},
};
if (RATE_LIMITS_DISABLED) {
app.use(
"/",
express.json(),
expressMiddleware(server, options)
);
} else {
const limiter = slowDown({
windowMs: RATE_LIMIT_WINDOW_MS,
delayAfter: RATE_LIMIT_DELAY_AFTER_REQUESTS,
delayMs: (hits) => {
return hits * RATE_LIMIT_DELAY_MULTIPLIER_MS;
}
});
app.use(
"/",
express.json(),
limiter,
expressMiddleware(server, options),
);
}
const port = process.env.PORT ? parseInt(process.env.PORT) : 4000;
app.listen(port, () => {
console.log(`Server ready at port ${port}`);
});
console.log(`Server ready at: ${url}`);
}
main();

View File

@@ -1,385 +0,0 @@
import { GetterSetterRepository } from "../repositories/GetterSetterRepository";
import { IEntityWithId, IEta, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
export class ApiResponseError extends Error {
constructor(message: string) {
super(message);
this.name = "ApiResponseError";
}
}
/**
* Class which can load data into a repository from the
* Passio Go API. Supports automatic pruning of all data types
* which inherit from `IEntityWithId`.
*/
export class ApiBasedRepositoryLoader {
supportedSystemIds = ["263"];
baseUrl = "https://passiogo.com/mapGetData.php";
constructor(
public repository: GetterSetterRepository,
) {
}
private async constructExistingEntityIdSet<T extends IEntityWithId>(entitySearchCallback: () => Promise<T[]>) {
const existingEntities = await entitySearchCallback();
const ids = new Set<string>();
existingEntities.forEach((entity) => {
ids.add(entity.id);
});
return ids;
}
public async fetchAndUpdateSystemData() {
const params = {
getSystems: "2",
};
const query = new URLSearchParams(params).toString();
const systemIds = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getSystems();
})
try {
const response = await fetch(`${this.baseUrl}?${query}`);
const json = await response.json();
if (!response.ok) {
throw new Error(`HTTP error with status ${response.status}`)
}
if (typeof json.all === "object") {
// filter down to supported systems
const filteredSystems = json.all.filter((jsonSystem: any) => this.supportedSystemIds.includes(jsonSystem.id));
await Promise.all(filteredSystems.map(async (system: any) => {
const constructedSystem: ISystem = {
id: system.id,
name: system.fullname,
};
await this.repository.addOrUpdateSystem(constructedSystem);
systemIds.delete(constructedSystem.id);
}));
} else {
throw new Error("Received JSON object does not contain `all` field")
}
// Prune systems
await Promise.all(Array.from(systemIds).map(async (systemId) => {
await this.repository.removeSystemIfExists(systemId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
}
public async fetchAndUpdateRouteDataForExistingSystemsInRepository() {
const systems = await this.repository.getSystems();
await Promise.all(systems.map(async (system) => {
await this.fetchAndUpdateRouteDataForSystemId(system.id);
}));
}
public async fetchAndUpdateRouteDataForSystemId(systemId: string) {
const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getRoutesBySystemId(systemId);
});
const params = {
getRoutes: "2",
};
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 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: systemId,
};
await this.repository.addOrUpdateRoute(constructedRoute);
routeIdsToPrune.delete(constructedRoute.id);
}))
}
await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => {
await this.repository.removeRouteIfExists(routeId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
}
public async fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository() {
const systems = await this.repository.getSystems();
await Promise.all(systems.map(async (system: ISystem) => {
await this.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(system.id);
}));
}
public async fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(systemId: string) {
// Fetch from the API
// Pass JSON output into two different methods to update repository
const stopIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getStopsBySystemId(systemId);
});
const params = {
getStops: "2",
};
const formDataJsonObject = {
"s0": systemId,
"sA": 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 json = await response.json();
await this.updateStopDataForSystemAndApiResponse(systemId, json, stopIdsToPrune);
await this.updateOrderedStopDataForExistingStops(json);
await this.updatePolylineDataForExistingRoutesAndApiResponse(json);
await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => {
await this.repository.removeStopIfExists(stopId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
}
public async fetchAndUpdateShuttleDataForExistingSystemsInRepository() {
const systems = await this.repository.getSystems();
await Promise.all(systems.map(async (system: ISystem) => {
const systemId = system.id;
await this.fetchAndUpdateShuttleDataForSystemId(systemId);
}));
}
public async fetchAndUpdateShuttleDataForSystemId(systemId: string) {
const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getShuttlesBySystemId(systemId);
});
const params = {
getBuses: "2"
};
const formDataJsonObject = {
"s0": systemId,
"sA": "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 json = await response.json();
if (json.buses && json.buses["-1"] === undefined) {
const jsonBuses = Object.values(json.buses).map((busesArr: any) => {
return busesArr[0];
});
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: systemId,
id: `${jsonBus.busId}`
}
await this.repository.addOrUpdateShuttle(constructedShuttle);
shuttleIdsToPrune.delete(constructedShuttle.id);
}));
}
await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => {
await this.repository.removeShuttleIfExists(shuttleId);
}));
} catch(e: any) {
throw new ApiResponseError(e.message);
}
}
public async fetchAndUpdateEtaDataForExistingStopsForSystemsInRepository() {
const systems = await this.repository.getSystems()
await Promise.all(systems.map(async (system: ISystem) => {
const systemId = system.id;
await this.fetchAndUpdateEtaDataForExistingStopsForSystemId(systemId);
}))
}
public async fetchAndUpdateEtaDataForExistingStopsForSystemId(systemId: string) {
const stops = await this.repository.getStopsBySystemId(systemId);
await Promise.all(stops.map(async (stop) => {
let stopId = stop.id;
await this.fetchAndUpdateEtaDataForStopId(stopId);
}));
}
public async fetchAndUpdateEtaDataForStopId(stopId: string) {
const params = {
eta: "3",
stopIds: stopId,
};
const query = new URLSearchParams(params).toString();
try {
const response = await fetch(`${this.baseUrl}?${query}`, {
method: "GET",
});
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,
millisecondsSinceEpoch: Date.now(),
};
this.repository.addOrUpdateEta(eta);
});
}
} catch(e: any) {
throw new ApiResponseError(e.message);
}
}
protected async updateStopDataForSystemAndApiResponse(
systemId: string,
json: any,
setOfIdsToPrune: Set<string> = new Set(),
) {
if (json.stops) {
const jsonStops = Object.values(json.stops);
await Promise.all(jsonStops.map(async (stop: any) => {
const constructedStop: IStop = {
name: stop.name,
id: stop.id,
systemId,
coordinates: {
latitude: parseFloat(stop.latitude),
longitude: parseFloat(stop.longitude),
},
};
await this.repository.addOrUpdateStop(constructedStop);
setOfIdsToPrune.delete(constructedStop.id);
}));
}
}
protected async updateOrderedStopDataForExistingStops(json: any) {
if (json.routes) {
await Promise.all(Object.keys(json.routes).map(async (routeId) => {
const jsonOrderedStopData: any[][] = json.routes[routeId].slice(2);
// The API may return the same stop twice to indicate that
// the route runs in a loop
// If the API does not do this, assume the route is one-way
// To account for this, check if ordered stop already exists in repo
// If it does, write to that stop instead
for (let index = 0; index < jsonOrderedStopData.length; index++) {
const orderedStopDataArray = jsonOrderedStopData[index];
const stopId = orderedStopDataArray[1];
let constructedOrderedStop = await this.repository.getOrderedStopByRouteAndStopId(routeId, stopId)
if (constructedOrderedStop === null) {
constructedOrderedStop = {
routeId,
stopId,
position: index + 1,
};
}
if (index >= 1) {
constructedOrderedStop.previousStop = {
routeId,
stopId: jsonOrderedStopData[index - 1][1],
position: index,
};
}
if (index < jsonOrderedStopData.length - 1) {
constructedOrderedStop.nextStop = {
routeId,
stopId: jsonOrderedStopData[index + 1][1],
position: index + 2,
};
}
await this.repository.addOrUpdateOrderedStop(constructedOrderedStop);
}
}));
}
}
protected async updatePolylineDataForExistingRoutesAndApiResponse(json: any) {
if (json.routePoints) {
await Promise.all(Object.keys(json.routePoints).map(async (routeId) => {
const routePoints = json.routePoints[routeId][0];
const existingRoute = await this.repository.getRouteById(routeId);
if (!existingRoute) return;
existingRoute.polylineCoordinates = routePoints.map((point: any) => {
return {
latitude: parseFloat(point.lat),
longitude: parseFloat(point.lng),
};
});
await this.repository.addOrUpdateRoute(existingRoute);
}))
}
}
}

View File

@@ -0,0 +1,6 @@
export class ApiResponseError extends Error {
constructor(message: string) {
super(message);
this.name = "ApiResponseError";
}
}

View File

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

View File

@@ -1,31 +1,17 @@
import { GetterSetterRepository } from "../repositories/GetterSetterRepository";
import { IEta, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
import { ApiBasedRepositoryLoader } from "./ApiBasedRepositoryLoader";
import { RepositoryLoader } from "./RepositoryLoader";
// Ideas to break this into smaller pieces in the future:
// Have one repository data loader running for each supported system
// Each data loader independently updates data based on frequency of usage
// To break down timed loading in the future:
// Add flags to the repository indicating which data users are subscribed to
// In the loader's `fetchAll` method, check flags and update only needed data
// Notes on this: we only need to reload ETA data frequently
// Other data can be reloaded periodically
// Detailed list:
// - ETA: reload frequently or switch to write-through approach
// - Shuttles: reload every minute
// - Routes: reload every few minutes
// - Stops: reload every few minutes
// - OrderedStops: reload every few minutes
// - Systems: reload once a day
export class TimedApiBasedRepositoryLoader extends ApiBasedRepositoryLoader {
export class TimedApiBasedRepositoryLoader {
private shouldBeRunning: boolean = false;
private timer: any;
readonly timeout = 10000;
constructor(
repository: GetterSetterRepository,
public loader: RepositoryLoader,
public readonly timeoutMs: number = 10000,
) {
super(repository);
this.startFetchDataAndUpdate = this.startFetchDataAndUpdate.bind(this);
}
@@ -47,19 +33,11 @@ export class TimedApiBasedRepositoryLoader extends ApiBasedRepositoryLoader {
if (!this.shouldBeRunning) return;
try {
await this.fetchAndUpdateSystemData();
await this.fetchAndUpdateRouteDataForExistingSystemsInRepository();
await this.fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository();
await this.fetchAndUpdateShuttleDataForExistingSystemsInRepository();
// Because ETA method doesn't support pruning yet,
// add a call to the clear method here
await this.repository.clearEtaData();
await this.fetchAndUpdateEtaDataForExistingStopsForSystemsInRepository();
await this.loader.updateAll();
} catch (e) {
console.error(e);
}
this.timer = setTimeout(this.startFetchDataAndUpdate, this.timeout);
this.timer = setTimeout(this.startFetchDataAndUpdate, this.timeoutMs);
}
}
}

View File

@@ -0,0 +1,68 @@
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";
import { ApiBasedShuttleRepositoryLoader } from "../shuttle/ApiBasedShuttleRepositoryLoader";
describe("TimedApiBasedRepositoryLoader", () => {
let timedLoader: TimedApiBasedRepositoryLoader;
let spies: any;
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(global, "setTimeout");
resetGlobalFetchMockJson();
const mockLoader = new ApiBasedShuttleRepositoryLoader(
"1",
"1",
new UnoptimizedInMemoryShuttleRepository(),
);
timedLoader = new TimedApiBasedRepositoryLoader(
mockLoader,
);
spies = {
updateAll: jest.spyOn(mockLoader, 'updateAll'),
};
Object.values(spies).forEach((spy: any) => {
spy.mockResolvedValue(undefined);
});
});
afterEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
})
describe("start", () => {
it("should update internal state, call data fetching methods, and start a timer", async () => {
await timedLoader.start();
expect(timedLoader["shouldBeRunning"]).toBe(true);
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalled();
});
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), timedLoader.timeoutMs);
expect(timedLoader.timeoutMs).not.toBeUndefined();
});
it("does nothing if timer is already running", async () => {
await timedLoader.start();
await timedLoader.start();
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
});
describe("stop", () => {
it("should update internal state", async () => {
timedLoader.stop();
expect(timedLoader['shouldBeRunning']).toBe(false);
});
});
});

View File

@@ -1,131 +0,0 @@
// Mock data
import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
import { GetterSetterRepository } from "../repositories/GetterSetterRepository";
// TODO: turn this into a class for testing reusability
const systems: ISystem[] = [
{
id: "1",
name: "Chapman University",
},
];
const routes: IRoute[] = [
{
name: "Red Route",
id: "1",
systemId: systems[0].id,
polylineCoordinates: [],
color: "#db2316",
},
{
name: "Teal Route",
id: "2",
systemId: systems[0].id,
polylineCoordinates: [],
color: "#21bdd1",
},
];
const stops: IStop[] = [
{
id: "1",
name: "Chapman Court",
coordinates: {
latitude: 33.796001,
longitude: -117.8892805,
},
systemId: systems[0].id,
},
{
id: "2",
name: "Chapman Grand",
coordinates: {
latitude: 33.804433,
longitude: -117.895966,
},
systemId: systems[0].id,
}
];
const orderedStopsForRedRoute: IOrderedStop[] = [
{
routeId: routes[0].id,
stopId: stops[0].id,
position: 1,
},
{
routeId: routes[0].id,
stopId: stops[1].id,
position: 2,
},
];
const orderedStopsForTealRoute: IOrderedStop[] = [
{
routeId: routes[1].id,
stopId: stops[1].id,
position: 1,
},
{
routeId: routes[1].id,
stopId: stops[0].id,
position: 2,
},
]
orderedStopsForRedRoute[0].nextStop = orderedStopsForRedRoute[1];
orderedStopsForRedRoute[1].previousStop = orderedStopsForRedRoute[0];
orderedStopsForTealRoute[0].nextStop = orderedStopsForTealRoute[1];
orderedStopsForTealRoute[1].previousStop = orderedStopsForTealRoute[0];
const shuttles: IShuttle[] = [
{
name: "Red Shuttle 17",
id: "1",
coordinates: {
latitude: 33.796001,
longitude: -117.8892805,
},
routeId: routes[0].id,
systemId: systems[0].id,
}
];
const etas: IEta[] = [
{
stopId: stops[0].id,
shuttleId: shuttles[0].id,
secondsRemaining: 12.023,
},
{
stopId: stops[1].id,
shuttleId: shuttles[0].id,
secondsRemaining: 600.123,
}
];
export async function loadTestData(repository: GetterSetterRepository) {
await Promise.all(systems.map(async (system) => {
await repository.addOrUpdateSystem(system);
}));
await Promise.all(routes.map(async (route) => {
await repository.addOrUpdateRoute(route);
}));
await Promise.all(shuttles.map(async (shuttle) => {
await repository.addOrUpdateShuttle(shuttle);
}));
await Promise.all(stops.map(async (stop) => {
await repository.addOrUpdateStop(stop);
}));
await Promise.all(orderedStopsForRedRoute.map(async (orderedStop) => {
await repository.addOrUpdateOrderedStop(orderedStop);
}));
await Promise.all(orderedStopsForTealRoute.map(async (orderedStop) => {
await repository.addOrUpdateOrderedStop(orderedStop);
}));
await Promise.all(etas.map(async (eta) => {
await repository.addOrUpdateEta(eta);
}));
}

View File

@@ -0,0 +1,84 @@
import { ParkingRepositoryLoader } from "./ParkingRepositoryLoader";
import { ParkingGetterSetterRepository } from "../../repositories/parking/ParkingGetterSetterRepository";
import { createHash } from "node:crypto";
import { ApiResponseError } from "../ApiResponseError";
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
class ApiParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ApiParseError";
}
}
export class ChapmanApiBasedParkingRepositoryLoader implements ParkingRepositoryLoader {
public static readonly id = "chapman-parking-loader";
private readonly fetchUrl = "https://webfarm.chapman.edu/ParkingService/ParkingService/counts";
constructor(
public repository: ParkingGetterSetterRepository
) {
this.fetchAndUpdateParkingStructures = this.fetchAndUpdateParkingStructures.bind(this);
}
async updateAll() {
await this.fetchAndUpdateParkingStructures();
}
async fetchAndUpdateParkingStructures(): Promise<void> {
let json: any;
try {
const response = await fetch(this.fetchUrl);
json = await response.json();
} catch(e: any) {
throw new ApiResponseError(e.message);
}
try {
if (typeof json.Structures === "object") {
const parkingStructures: IParkingStructure[] = json.Structures.map(this.constructIParkingStructureFromJson);
await Promise.all(parkingStructures.map(async (structure: IParkingStructure) => {
await this.repository.addOrUpdateParkingStructure(structure);
}));
}
} catch(e: any) {
throw new ApiParseError(e.message);
}
}
public constructIParkingStructureFromJson(jsonStructure: any) {
const structureToReturn: IParkingStructure = {
capacity: jsonStructure.Capacity,
coordinates: {
latitude: jsonStructure.Latitude,
longitude: jsonStructure.Longitude,
},
id: ChapmanApiBasedParkingRepositoryLoader.generateId(jsonStructure.Address),
name: jsonStructure.Name,
spotsAvailable: jsonStructure.CurrentCount > jsonStructure.Capacity ? jsonStructure.Capacity : jsonStructure.CurrentCount,
address: jsonStructure.Address,
updatedTime: new Date(),
}
return structureToReturn;
}
private static normalizeAddress(address: string): string {
return address
.toLowerCase()
.split(/\s+/)
.filter(part => part.length > 0)
.join(' ');
}
public static generateId(address: string): string {
const normalized = this.normalizeAddress(address);
const hash = createHash('sha256')
.update(normalized)
.digest('hex');
return hash.substring(0, 32);
}
}

View File

@@ -0,0 +1,5 @@
import { RepositoryLoader } from "../RepositoryLoader";
export interface ParkingRepositoryLoader extends RepositoryLoader {
fetchAndUpdateParkingStructures(): Promise<void>;
}

View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
ChapmanApiBasedParkingRepositoryLoader
} from "../ChapmanApiBasedParkingRepositoryLoader";
import { InMemoryParkingRepository } from "../../../repositories/parking/InMemoryParkingRepository";
import {
resetGlobalFetchMockJson,
updateGlobalFetchMockJson,
updateGlobalFetchMockJsonToThrowSyntaxError
} from "../../../../testHelpers/fetchMockHelpers";
import {
chapmanParkingStructureData
} from "../../../../testHelpers/jsonSnapshots/chapmanParkingStructureData/chapmanParkingStructureData";
import { IParkingStructure } from "../../../entities/ParkingRepositoryEntities";
import { assertAsyncCallbackThrowsApiResponseError } from "../../../../testHelpers/assertAsyncCallbackThrowsApiResponseError";
describe("ChapmanApiBasedParkingRepositoryLoader", () => {
let loader: ChapmanApiBasedParkingRepositoryLoader;
beforeEach(() => {
loader = new ChapmanApiBasedParkingRepositoryLoader(
new InMemoryParkingRepository(),
);
resetGlobalFetchMockJson();
});
describe("fetchAndUpdateAll", () => {
it("calls all the correct methods", async () => {
const spies = {
fetchAndUpdateParkingStructures: jest.spyOn(loader, "fetchAndUpdateParkingStructures"),
};
Object.values(spies).forEach((spy: any) => {
spy.mockResolvedValue(undefined);
});
await loader.updateAll();
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalled();
});
});
});
describe("fetchAndUpdateParkingStructures", () => {
it("fetches and update parking structures with unique IDs", async () => {
updateGlobalFetchMockJson(chapmanParkingStructureData);
await loader.fetchAndUpdateParkingStructures();
let expectedStructures: IParkingStructure[] = [
{
address: "300 E Walnut, Orange, CA 92867",
capacity: 871,
spotsAvailable: 211,
coordinates: {
latitude: 33.7945513,
longitude: -117.8518707,
},
name: "Anderson Structure",
id: "",
updatedTime: new Date(),
},
{
address: "200 W Sycamore Ave, Orange, CA 92866-1053",
capacity: 692,
spotsAvailable: 282,
coordinates: {
latitude: 33.792937,
longitude: -117.854782
},
name: "Barrera",
id: "",
updatedTime: new Date(),
}
];
expectedStructures[0].id = ChapmanApiBasedParkingRepositoryLoader.generateId(expectedStructures[0].address);
expectedStructures[1].id = ChapmanApiBasedParkingRepositoryLoader.generateId(expectedStructures[1].address);
const structuresFromLoader = await loader.repository.getParkingStructures();
// Set updatedTimeMs on expected data to avoid comparison
expectedStructures[0].updatedTime = structuresFromLoader[0].updatedTime;
expectedStructures[1].updatedTime = structuresFromLoader[1].updatedTime;
expect(structuresFromLoader).toEqual(expectedStructures);
});
it("throws ApiResponseError if data is incorrect", async () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateParkingStructures();
});
})
});
describe("constructIParkingStructureFromJson", () => {
it("normalizes the spots available if it's over the capacity", async () => {
const sampleJsonStructure: any = {
Capacity: 10,
Latitude: 1,
Longitude: 1,
Address: "300 E Walnut, Orange, CA 92867",
Name: "Anderson Structure",
CurrentCount: 11,
};
const returnedStructure = loader.constructIParkingStructureFromJson(sampleJsonStructure);
expect(returnedStructure.spotsAvailable).toEqual(returnedStructure.capacity);
});
});
});

View File

@@ -0,0 +1,15 @@
import { ParkingGetterSetterRepository } from "../../repositories/parking/ParkingGetterSetterRepository";
import { ChapmanApiBasedParkingRepositoryLoader } from "./ChapmanApiBasedParkingRepositoryLoader";
export interface ParkingRepositoryLoaderBuilderArguments {
id: string;
repository: ParkingGetterSetterRepository;
}
export function buildParkingRepositoryLoaderIfExists(args: ParkingRepositoryLoaderBuilderArguments) {
if (args.id === ChapmanApiBasedParkingRepositoryLoader.id) {
return new ChapmanApiBasedParkingRepositoryLoader(args.repository);
}
return null;
}

View File

@@ -0,0 +1,35 @@
import { ParkingGetterSetterRepository } from "../../repositories/parking/ParkingGetterSetterRepository";
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
const parkingStructures: IParkingStructure[] = [
{
address: "300 E Walnut, Orange, CA 92867",
capacity: 871,
spotsAvailable: 211,
coordinates: {
latitude: 33.7945513,
longitude: -117.8518707,
},
name: "Anderson Structure",
id: "1",
updatedTime: new Date(),
},
{
address: "200 W Sycamore Ave, Orange, CA 92866-1053",
capacity: 692,
spotsAvailable: 282,
coordinates: {
latitude: 33.792937,
longitude: -117.854782
},
name: "Barrera",
id: "2",
updatedTime: new Date(),
}
];
export async function loadParkingTestData(repository: ParkingGetterSetterRepository) {
await Promise.all(parkingStructures.map(async structure => {
await repository.addOrUpdateParkingStructure(structure);
}))
}

View File

@@ -0,0 +1,441 @@
import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository";
import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader";
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
* Passio Go API. Supports automatic pruning of all data types
* which inherit from `IEntityWithId`.
*/
export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader {
readonly baseUrl = "https://passiogo.com/mapGetData.php";
constructor(
public passioSystemId: string,
public systemIdForConstructedData: string,
public shuttleRepository: ShuttleGetterSetterRepository,
public etaRepository?: ExternalSourceETARepository,
readonly shuttleToRouteCoordinateMaximumDistanceMiles = SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES,
) {
}
private async constructExistingEntityIdSet<T extends IEntityWithId>(entitySearchCallback: () => Promise<T[]>) {
const existingEntities = await entitySearchCallback();
const ids = new Set<string>();
existingEntities.forEach((entity) => {
ids.add(entity.id);
});
return ids;
}
public async updateAll() {
await this.updateRouteDataForSystem();
await this.updateStopAndPolylineDataForRoutesInSystem();
await this.updateShuttleDataForSystemBasedOnProximityToRoutes();
if (this.etaRepository) {
await this.updateEtaDataForExistingStopsForSystem();
}
}
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.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",
};
const formDataJsonObject = {
"systemSelected0": systemId,
"amount": "1",
};
const formData = new FormData();
formData.set("json", JSON.stringify(formDataJsonObject));
const query = new URLSearchParams(params).toString();
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;
});
}
return null;
}
public async updateStopAndPolylineDataForRoutesInSystem() {
try {
const json = await this.fetchStopAndPolylineDataJson();
await this.updateStopAndPolylineDataInRepository(json);
} catch(e: any) {
throw new ApiResponseError(e.message);
}
}
private async updateStopAndPolylineDataInRepository(json: any) {
const stopIdsToPrune = await this.constructExistingEntityIdSet(async () => {
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",
};
const formDataJsonObject = {
"s0": passioSystemId,
"sA": 1
};
const formData = new FormData();
formData.set("json", JSON.stringify(formDataJsonObject));
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 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);
}
}
private async updateShuttleDataInRepository(shuttles: IShuttle[]) {
const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => {
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"
};
const formDataJsonObject = {
"s0": systemId,
"sA": "1"
};
const formData = new FormData();
formData.set("json", JSON.stringify(formDataJsonObject));
const query = new URLSearchParams(params).toString();
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];
});
jsonBuses.filter((bus) => bus.outOfService != 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;
});
}
return null;
}
public async updateEtaDataForExistingStopsForSystem() {
const stops = await this.shuttleRepository.getStops();
await Promise.all(stops.map(async (stop) => {
let stopId = stop.id;
await this.updateEtaDataForStopId(stopId);
}));
}
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)}`);
}
} catch(e: any) {
throw new ApiResponseError(e.message);
}
}
private async updateEtaDataInRepository(etas: IEta[]) {
await Promise.all(etas.map(async (eta) => {
await this.etaRepository?.addOrUpdateEtaFromExternalSource(eta);
}));
}
private async fetchEtaDataJson(stopId: string) {
const params = {
eta: "3",
stopIds: stopId,
};
const query = new URLSearchParams(params).toString();
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;
});
}
return null;
}
protected async updateStopDataForSystemAndApiResponse(
json: any,
setOfIdsToPrune: Set<string> = new Set(),
) {
if (json.stops) {
const jsonStops = Object.values(json.stops);
await Promise.all(jsonStops.map(async (stop: any) => {
const constructedStop: IStop = {
name: stop.name,
id: stop.id,
systemId: this.systemIdForConstructedData,
coordinates: {
latitude: parseFloat(stop.latitude),
longitude: parseFloat(stop.longitude),
},
updatedTime: new Date(),
};
await this.shuttleRepository.addOrUpdateStop(constructedStop);
setOfIdsToPrune.delete(constructedStop.id);
}));
}
}
protected async updateOrderedStopDataForExistingStops(json: any) {
if (json.routes) {
await Promise.all(Object.keys(json.routes).map(async (routeId) => {
const jsonOrderedStopData: any[][] = json.routes[routeId].slice(2);
// The API may return the same stop twice to indicate that
// the route runs in a loop
// If the API does not do this, assume the route is one-way
// To account for this, check if ordered stop already exists in repo
// If it does, write to that stop instead
for (let index = 0; index < jsonOrderedStopData.length; index++) {
const orderedStopDataArray = jsonOrderedStopData[index];
const stopId = orderedStopDataArray[1];
let constructedOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(routeId, stopId)
if (constructedOrderedStop === null) {
constructedOrderedStop = {
routeId,
stopId,
position: index + 1,
systemId: this.systemIdForConstructedData,
updatedTime: new Date(),
};
}
if (index >= 1) {
constructedOrderedStop.previousStop = {
routeId,
stopId: jsonOrderedStopData[index - 1][1],
position: index,
systemId: this.systemIdForConstructedData,
updatedTime: new Date(),
};
}
if (index < jsonOrderedStopData.length - 1) {
constructedOrderedStop.nextStop = {
routeId,
stopId: jsonOrderedStopData[index + 1][1],
position: index + 2,
systemId: this.systemIdForConstructedData,
updatedTime: new Date(),
};
}
await this.shuttleRepository.addOrUpdateOrderedStop(constructedOrderedStop);
}
}));
}
}
protected async updatePolylineDataForExistingRoutesAndApiResponse(json: any) {
if (json.routePoints) {
await Promise.all(Object.keys(json.routePoints).map(async (routeId) => {
const routePoints = json.routePoints[routeId][0];
const existingRoute = await this.shuttleRepository.getRouteById(routeId);
if (!existingRoute) return;
existingRoute.polylineCoordinates = routePoints.map((point: any) => {
return {
latitude: parseFloat(point.lat),
longitude: parseFloat(point.lng),
};
});
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

@@ -0,0 +1,9 @@
import { RepositoryLoader } from "../RepositoryLoader";
export interface ShuttleRepositoryLoader extends RepositoryLoader {
updateRouteDataForSystem(): Promise<void>;
updateStopAndPolylineDataForRoutesInSystem(): Promise<void>;
updateShuttleDataForSystemBasedOnProximityToRoutes(): Promise<void>;
updateEtaDataForExistingStopsForSystem(): Promise<void>;
updateEtaDataForStopId(stopId: string): Promise<void>;
}

View File

@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
import { ApiBasedShuttleRepositoryLoader } from "../ApiBasedShuttleRepositoryLoader";
import { UnoptimizedInMemoryShuttleRepository } from "../../../repositories/shuttle/UnoptimizedInMemoryShuttleRepository";
import { fetchRouteDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchRouteData/fetchRouteDataSuccessfulResponse";
import {
fetchStopAndPolylineDataSuccessfulResponse
} from "../../../../testHelpers/jsonSnapshots/fetchStopAndPolylineData/fetchStopAndPolylineDataSuccessfulResponse";
import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../../../../testHelpers/mockDataGenerators";
import {
fetchShuttleDataSuccessfulResponse
} from "../../../../testHelpers/jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse";
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;
beforeEach(() => {
loader = new ApiBasedShuttleRepositoryLoader("263", "1", new UnoptimizedInMemoryShuttleRepository());
resetGlobalFetchMockJson();
});
afterEach(() => {
jest.clearAllMocks();
});
const systemId = "1";
describe("updateAll", () => {
it("calls all the correct methods", async () => {
const spies = {
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.updateAll();
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalled();
});
});
});
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.shuttleRepository.addOrUpdateRoute(route);
}));
updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse);
// Act
await loader.updateRouteDataForSystem();
// Assert
const routes = await loader.shuttleRepository.getRoutes();
expect(routes.length).toEqual(fetchRouteDataSuccessfulResponse.all.length)
});
it("throws the correct error if the API response contains no data", async () => {
// The Passio API returns some invalid JSON if there is no data,
// so simulate a JSON parsing error
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.updateRouteDataForSystem();
});
});
});
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.shuttleRepository.addOrUpdateStop(stop);
}));
updateGlobalFetchMockJson(fetchStopAndPolylineDataSuccessfulResponse);
const stopsArray = Object.values(fetchStopAndPolylineDataSuccessfulResponse.stops);
await loader.updateStopAndPolylineDataForRoutesInSystem();
const stops = await loader.shuttleRepository.getStops();
expect(stops.length).toEqual(stopsArray.length);
await Promise.all(stops.map(async (stop) => {
const orderedStops = await loader.shuttleRepository.getOrderedStopsByStopId(stop.id)
expect(orderedStops.length).toBeGreaterThan(0);
}));
const routes = await loader.shuttleRepository.getRoutes();
routes.forEach((route) => {
expect(route.polylineCoordinates.length).toBeGreaterThan(0);
});
});
it("throws the correct error if the API response contains no data", async () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.updateStopAndPolylineDataForRoutesInSystem();
});
})
});
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;
}
function getMockJsonResponseMatchingRouteAndCoordinates(route: IRoute, longitude: string, latitude: string) {
const modifiedSuccessfulResponse = {
...fetchShuttleDataSuccessfulResponse,
};
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;
}
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("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,
);
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);
});
it("prunes shuttles correctly", async () => {
const distanceMiles = 1;
loader = new ApiBasedShuttleRepositoryLoader(
"263",
"1",
new UnoptimizedInMemoryShuttleRepository(),
new InMemoryExternalSourceETARepository(),
distanceMiles,
);
// 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);
}));
const routes = generateMockRoutesWithPolylineCoordinates();
await addMockRoutes(routes);
const modifiedSuccessfulResponse = getMockJsonResponseMatchingRouteAndCoordinates(
routes[0],
"-117.86187",
"33.78792"
);
updateGlobalFetchMockJson(modifiedSuccessfulResponse);
// Update shuttles from API
await loader.updateShuttleDataForSystemBasedOnProximityToRoutes();
// 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.updateShuttleDataForSystemBasedOnProximityToRoutes();
});
});
});
});

View File

@@ -0,0 +1,101 @@
import { IEta } from "../../entities/ShuttleRepositoryEntities";
import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender";
import {
NotificationRepository,
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(),
private interchangeSystemId: string,
) {
this.etaSubscriberCallback = this.etaSubscriberCallback.bind(this);
this.sendEtaNotificationImmediately = this.sendEtaNotificationImmediately.bind(this);
this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold = this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold.bind(this);
}
private async sendEtaNotificationImmediately(notificationData: ScheduledNotification): Promise<boolean> {
const { deviceId, shuttleId, stopId } = notificationData;
const shuttle = await this.shuttleRepository.getShuttleById(shuttleId);
const stop = await this.shuttleRepository.getStopById(stopId);
const eta = await this.etaRepository.getEtaForShuttleAndStopId(shuttleId, stopId);
if (!shuttle) {
console.warn(`Notification ${notificationData} fell through; no associated shuttle`);
return false;
}
if (!stop) {
console.warn(`Notification ${notificationData} fell through; no associated stop`);
return false;
}
// Notification may not be sent if ETA is unavailable at the moment;
// this is fine because it will be sent again when ETA becomes available
if (!eta) {
console.warn(`Notification ${notificationData} fell through; no associated ETA`);
return false;
}
const notificationAlertArguments: NotificationAlertArguments = {
title: "Shuttle is arriving",
body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.`,
customKeys: {
shuttleEtaNotificationInfo: {
shuttleId,
stopId,
systemId: this.interchangeSystemId,
}
},
}
return this.appleNotificationSender.sendNotificationImmediately(deviceId, notificationAlertArguments);
}
private async etaSubscriberCallback(eta: IEta) {
const deviceIdsToRemove = new Set<string>();
const notifications = await this.notificationRepository.getAllNotificationsForShuttleAndStopId(
eta.shuttleId,
eta.stopId
)
for (let notification of notifications) {
const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(notification, eta.secondsRemaining);
if (deliveredSuccessfully) {
deviceIdsToRemove.add(notification.deviceId);
}
}
deviceIdsToRemove.forEach((deviceId) => {
this.notificationRepository.deleteNotificationIfExists({
shuttleId: eta.shuttleId,
stopId: eta.stopId,
deviceId,
})
});
}
private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(notificationObject: ScheduledNotification, etaSecondsRemaining: number) {
if (etaSecondsRemaining > notificationObject.secondsThreshold) {
return false;
}
return await this.sendEtaNotificationImmediately(notificationObject);
}
// The following is a workaround for the constructor being called twice
public startListeningForUpdates() {
this.etaRepository.on(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback);
}
public stopListeningForUpdates() {
this.etaRepository.off(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback);
}
}

View File

@@ -0,0 +1,154 @@
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";
import { InMemoryNotificationRepository } from "../../../repositories/notifications/InMemoryNotificationRepository";
import { NotificationRepository } from "../../../repositories/notifications/NotificationRepository";
jest.mock("http2");
jest.mock("../../senders/AppleNotificationSender");
const MockAppleNotificationSender = AppleNotificationSender as jest.MockedClass<typeof AppleNotificationSender>;
function mockNotificationSenderMethods(shouldSimulateNotificationSend: boolean) {
MockAppleNotificationSender.prototype.sendNotificationImmediately = jest.fn(async () => shouldSimulateNotificationSend);
}
/**
* Wait for a specified number of milliseconds.
* @param ms
*/
async function waitForMilliseconds(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
describe("ETANotificationScheduler", () => {
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,
"1",
);
notificationService.startListeningForUpdates();
});
function generateNotificationDataAndEta(shuttle: IShuttle, stop: IStop) {
const eta: IEta = {
shuttleId: shuttle.id,
stopId: stop.id,
secondsRemaining: 120,
systemId: "1",
updatedTime: new Date(),
};
const notificationData1 = {
deviceId: "1",
shuttleId: eta.shuttleId,
stopId: eta.stopId,
secondsThreshold: 240,
}
const notificationData2 = {
...notificationData1,
deviceId: "2",
secondsThreshold: 180,
}
return { eta, notificationData1, notificationData2 };
}
describe("etaSubscriberCallback", () => {
it("sends and clears correct notification after ETA changed", async () => {
// Arrange
const shuttle = await addMockShuttleToRepository(shuttleRepository, "1");
const stop = await addMockStopToRepository(shuttleRepository, "1");
const { eta, notificationData1, notificationData2 } = generateNotificationDataAndEta(shuttle, stop);
// Act
await notificationRepository.addOrUpdateNotification(notificationData1);
await notificationRepository.addOrUpdateNotification(notificationData2);
await etaRepository.addOrUpdateEtaFromExternalSource(eta);
// Assert
// Wait for the callback to actually be called
await waitForMilliseconds(1000);
const isFirstNotificationScheduled = await notificationRepository.isNotificationScheduled(notificationData1);
const isSecondNotificationScheduled = await notificationRepository.isNotificationScheduled(notificationData2);
// No longer scheduled after being sent
expect(isFirstNotificationScheduled).toBe(false);
expect(isSecondNotificationScheduled).toBe(false);
});
it("doesn't send notification if seconds threshold not exceeded", async () => {
// Arrange
const shuttle = await addMockShuttleToRepository(shuttleRepository, "1");
const stop = await addMockStopToRepository(shuttleRepository, "1");
const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop);
notificationData1.secondsThreshold = eta.secondsRemaining - 10;
// Act
await notificationRepository.addOrUpdateNotification(notificationData1);
await etaRepository.addOrUpdateEtaFromExternalSource(eta);
// Assert
await waitForMilliseconds(500);
const isNotificationScheduled = await notificationRepository.isNotificationScheduled(notificationData1);
expect(isNotificationScheduled).toBe(true);
});
it("leaves notification in array if delivery unsuccessful", async () => {
// Arrange
const shuttle = await addMockShuttleToRepository(shuttleRepository, "1");
const stop = await addMockStopToRepository(shuttleRepository, "1");
const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop)
// replace the old notification scheduler with a new one
// detach the old callback method from the shuttle repo
notificationService.stopListeningForUpdates();
// replace the notification repository with a fresh one too
const notificationRepository = new InMemoryNotificationRepository();
mockNotificationSenderMethods(false);
const updatedNotificationSender = new MockAppleNotificationSender(false);
notificationService = new ETANotificationScheduler(
etaRepository,
shuttleRepository,
notificationRepository,
updatedNotificationSender,
"1",
);
notificationService.startListeningForUpdates();
// Act
await notificationRepository.addOrUpdateNotification(notificationData1);
await etaRepository.addOrUpdateEtaFromExternalSource(eta);
// Assert
// The notification should stay scheduled to be retried once
// the ETA updates again
await waitForMilliseconds(500);
const isNotificationScheduled = await notificationRepository.isNotificationScheduled(notificationData1);
expect(isNotificationScheduled).toBe(true);
});
});
});

View File

@@ -0,0 +1,192 @@
import jwt from "jsonwebtoken";
import http2 from "http2";
import { ClientHttp2Session } from "node:http2";
interface APNsUrl {
fullUrl: string;
path: string;
host: string;
}
export interface NotificationAlertArguments {
title: string;
body: string;
customKeys?: any,
}
export class AppleNotificationSender {
private apnsToken: string | undefined = undefined;
private _lastRefreshedTimeMs: Date | undefined = undefined;
constructor(
private shouldActuallySendNotifications = true,
private client: ClientHttp2Session | undefined = undefined,
) {
this.sendNotificationImmediately = this.sendNotificationImmediately.bind(this);
this.lastReloadedTimeForAPNsIsTooRecent = this.lastReloadedTimeForAPNsIsTooRecent.bind(this);
this.reloadAPNsTokenIfTimePassed = this.reloadAPNsTokenIfTimePassed.bind(this);
this.openConnectionIfNoneExists = this.openConnectionIfNoneExists.bind(this);
this.closeConnectionIfExists = this.closeConnectionIfExists.bind(this);
this.registerClosureEventsForClient = this.registerClosureEventsForClient.bind(this);
if (this.client !== undefined) {
this.registerClosureEventsForClient();
}
}
get lastRefreshedTimeMs(): Date | undefined {
return this._lastRefreshedTimeMs;
}
private lastReloadedTimeForAPNsIsTooRecent() {
const thirtyMinutesMs = 1800000;
return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs.getTime() < thirtyMinutesMs;
}
public reloadAPNsTokenIfTimePassed() {
if (this.lastReloadedTimeForAPNsIsTooRecent()) {
return;
}
const keyId = process.env.APNS_KEY_ID;
const teamId = process.env.APNS_TEAM_ID;
const privateKeyBase64 = process.env.APNS_PRIVATE_KEY;
if (!privateKeyBase64) return;
const privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8');
const tokenHeader = {
alg: "ES256",
"kid": keyId,
};
const nowMs = Date.now();
const claimsPayload = {
"iss": teamId,
"iat": Math.ceil(nowMs / 1000), // APNs requires number of seconds since Epoch
};
this.apnsToken = jwt.sign(claimsPayload, privateKey, {
algorithm: "ES256",
header: tokenHeader
});
this._lastRefreshedTimeMs = new Date(nowMs);
}
/**
* Send a notification immediately.
* @param deviceId
* @param notificationAlertArguments
*
* @return Boolean promise indicating whether the
* notification was sent successfully.
*/
public async sendNotificationImmediately(deviceId: string, notificationAlertArguments: NotificationAlertArguments) {
if (!this.shouldActuallySendNotifications) {
// pretend that the notification sent
return true;
}
this.reloadAPNsTokenIfTimePassed();
const bundleId = process.env.APNS_BUNDLE_ID;
if (typeof bundleId !== "string") {
throw new Error("APNS_BUNDLE_ID environment variable is not set correctly");
}
this.openConnectionIfNoneExists();
const { path } = AppleNotificationSender.getAPNsFullUrlToUse(deviceId);
const headers = {
':method': 'POST',
':path': path,
'authorization': `bearer ${this.apnsToken}`,
"apns-push-type": "alert",
"apns-expiration": "0",
"apns-priority": "10",
"apns-topic": bundleId,
};
try {
if (!this.client) { return false }
const req = this.client.request(headers);
req.setEncoding('utf8');
await new Promise<void>((resolve, reject) => {
req.on('response', (headers, _flags) => {
if (headers[":status"] !== 200) {
reject(`APNs request failed with status ${headers[":status"]}`);
}
resolve();
});
const customKeys = {
...notificationAlertArguments.customKeys,
}
delete notificationAlertArguments.customKeys;
// See https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification
// for notification payload examples
const payload = JSON.stringify({
aps: {
alert: notificationAlertArguments,
sound: "default"
},
...customKeys,
});
req.write(payload);
req.end();
});
return true;
} catch(e) {
console.error(e);
return false;
}
}
private openConnectionIfNoneExists() {
const host = AppleNotificationSender.getAPNsHostToUse();
if (!this.client) {
this.client = http2.connect(host);
this.registerClosureEventsForClient();
}
}
private registerClosureEventsForClient() {
this.client?.on('close', this.closeConnectionIfExists);
this.client?.on('error', this.closeConnectionIfExists);
this.client?.on('goaway', this.closeConnectionIfExists);
this.client?.on('timeout', this.closeConnectionIfExists);
}
private closeConnectionIfExists() {
this.client?.close();
this.client = undefined;
}
public static getAPNsFullUrlToUse(deviceId: string): APNsUrl {
let hostToUse = this.getAPNsHostToUse();
const path = "/3/device/" + deviceId;
const fullUrl = hostToUse + path;
return {
fullUrl,
host: hostToUse,
path,
};
}
public static getAPNsHostToUse() {
// Construct the fetch request
const devBaseUrl = "https://api.development.push.apple.com"
const prodBaseUrl = "https://api.push.apple.com"
let hostToUse = devBaseUrl;
if (process.env.APNS_IS_PRODUCTION === "1") {
hostToUse = prodBaseUrl;
}
return hostToUse;
}
}

View File

@@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import http2 from "http2";
import { EventEmitter } from "node:events";
import {
AppleNotificationSender,
NotificationAlertArguments
} from "../AppleNotificationSender";
import { ClientHttp2Session } from "node:http2";
jest.mock("http2");
const sampleKeyBase64 = "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR1RBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJIa3dkd0lCQVFRZ3NybVNBWklhZ09mQ1A4c0IKV2kyQ0JYRzFPbzd2MWJpc3BJWkN3SXI0UkRlZ0NnWUlLb1pJemowREFRZWhSQU5DQUFUWkh4VjJ3UUpMTUJxKwp5YSt5ZkdpM2cyWlV2NmhyZmUrajA4eXRla1BIalhTMHF6Sm9WRUx6S0hhNkVMOVlBb1pEWEJ0QjZoK2ZHaFhlClNPY09OYmFmCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K";
class MockClient extends EventEmitter {
constructor(
private status: number,
) {
super()
}
request = jest.fn((_) => {
const mockRequest: any = new EventEmitter();
mockRequest.setEncoding = jest.fn();
mockRequest.write = jest.fn();
mockRequest.end = jest.fn(() => {
setTimeout(() => {
mockRequest.emit('response', { ':status': this.status });
}, 10);
});
return mockRequest;
});
close = jest.fn(() => {});
}
function mockHttp2Connect(status: number) {
(http2.connect as jest.Mock) = jest.fn(() => new MockClient(status));
}
describe("AppleNotificationSender", () => {
let notificationSender: AppleNotificationSender;
beforeEach(() => {
notificationSender = new AppleNotificationSender();
// Ensure that tests don't hit the server
process.env = {
...process.env,
APNS_KEY_ID: "1",
APNS_TEAM_ID: "1",
APNS_BUNDLE_ID: "dev.bchen.ProjectInter",
APNS_PRIVATE_KEY: sampleKeyBase64,
};
});
beforeEach(() => {
mockHttp2Connect(200);
});
describe("reloadAPNsTokenIfTimePassed", () => {
it("reloads the token if token hasn't been generated yet", async () => {
notificationSender.reloadAPNsTokenIfTimePassed();
expect(notificationSender.lastRefreshedTimeMs).toBeDefined();
});
it("doesn't reload the token if last refreshed time is recent", async () => {
notificationSender.reloadAPNsTokenIfTimePassed();
const lastRefreshedTimeMs = notificationSender.lastRefreshedTimeMs;
notificationSender.reloadAPNsTokenIfTimePassed();
// Expect no change to have occurred
expect(lastRefreshedTimeMs).toEqual(notificationSender.lastRefreshedTimeMs);
});
});
describe('getAPNsFullUrlToUse', () => {
it('should return the production URL when APNS_IS_PRODUCTION is set to "1"', () => {
process.env.APNS_IS_PRODUCTION = "1";
const deviceId = 'testDeviceId';
const result = AppleNotificationSender.getAPNsFullUrlToUse(deviceId);
const { fullUrl, host, path } = result;
expect(fullUrl).toBe(`https://api.push.apple.com/3/device/${deviceId}`);
expect(host).toBe("https://api.push.apple.com");
expect(path).toBe(`/3/device/${deviceId}`);
});
it('should return the sandbox URL when APNS_IS_PRODUCTION is set to something other than 1', () => {
process.env.APNS_IS_PRODUCTION = "0";
const deviceId = 'testDeviceId';
const result = AppleNotificationSender.getAPNsFullUrlToUse(deviceId);
const { fullUrl, host, path } = result;
expect(fullUrl).toBe(`https://api.development.push.apple.com/3/device/${deviceId}`);
expect(host).toBe("https://api.development.push.apple.com");
expect(path).toBe(`/3/device/${deviceId}`);
});
});
describe("sendNotificationImmediately", () => {
it('makes the connection to the http server if sending a notification for the first time', async () => {
const notificationArguments: NotificationAlertArguments = {
title: 'Test notification',
body: 'This notification will send',
}
const result = await notificationSender.sendNotificationImmediately('1', notificationArguments);
expect(http2.connect).toHaveBeenCalled();
expect(result).toBe(true);
});
it('reuses the existing connection if sending another notification', async () => {
const notificationArguments: NotificationAlertArguments = {
title: 'Test notification',
body: 'This notification will send',
}
const result1 = await notificationSender.sendNotificationImmediately('1', notificationArguments);
const result2 = await notificationSender.sendNotificationImmediately('1', notificationArguments);
expect(http2.connect).toHaveBeenCalledTimes(1);
expect(result1).toBe(true);
expect(result2).toBe(true);
});
it('throws an error if the bundle ID is not set correctly', async () => {
process.env = {
...process.env,
APNS_BUNDLE_ID: undefined,
}
const notificationArguments: NotificationAlertArguments = {
title: 'Test notification',
body: 'This notification will not send',
}
await expect(async () => {
await notificationSender.sendNotificationImmediately('1', notificationArguments);
}).rejects.toThrow();
});
it('returns false if there is an error sending the notification', async () => {
mockHttp2Connect(403);
const notificationArguments: NotificationAlertArguments = {
title: 'Test notification',
body: 'This notification will not send',
}
const result = await notificationSender.sendNotificationImmediately('1', notificationArguments);
expect(http2.connect).toHaveBeenCalled();
expect(result).toBe(false);
});
it('does not send notification if shouldActuallySendNotifications is false', async () => {
notificationSender = new AppleNotificationSender(false);
const notificationArguments: NotificationAlertArguments = {
title: 'Test notification',
body: 'This notification should not send',
}
const result = await notificationSender.sendNotificationImmediately('1', notificationArguments);
expect(http2.connect).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it("registers a handler to close the connection if `close` event fired", async () => {
const connectionCloseEvents = ['close', 'goaway', 'error', 'timeout'];
await Promise.all(connectionCloseEvents.map(async (event) => {
const mockClient = new MockClient(200);
notificationSender = new AppleNotificationSender(true, mockClient as unknown as ClientHttp2Session);
const notificationArguments: NotificationAlertArguments = {
title: 'Test notification',
body: ''
};
await notificationSender.sendNotificationImmediately('1', notificationArguments);
mockClient.emit(event);
expect(mockClient.close).toHaveBeenCalled();
}));
});
});
});

View File

@@ -0,0 +1,29 @@
import { RedisClientType } from 'redis';
import { EventEmitter } from 'stream';
import createRedisClientForRepository from '../helpers/createRedisClientForRepository';
export abstract class BaseRedisRepository extends EventEmitter {
protected redisClient;
constructor(
redisClient: RedisClientType = createRedisClientForRepository(),
) {
super();
this.redisClient = redisClient;
this.redisClient.on('error', (err) => {
console.error(err.stack);
});
}
get isReady() {
return this.redisClient.isReady;
}
public async connect() {
await this.redisClient.connect();
}
public async disconnect() {
await this.redisClient.disconnect();
}
}

View File

@@ -1,37 +0,0 @@
import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
export interface GetterRepository {
getSystems(): Promise<ISystem[]>;
getSystemById(systemId: string): Promise<ISystem | null>;
getStopsBySystemId(systemId: string): Promise<IStop[]>;
getStopById(stopId: string): Promise<IStop | null>;
getRoutesBySystemId(systemId: string): Promise<IRoute[]>;
getRouteById(routeId: string): Promise<IRoute | null>;
getShuttlesBySystemId(systemId: string): Promise<IShuttle[]>;
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>;
getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise<IOrderedStop | null>;
/**
* Get ordered stops with the given stop ID.
* Returns an empty array if no ordered stops found.
* @param stopId
*/
getOrderedStopsByStopId(stopId: string): Promise<IOrderedStop[]>;
/**
* Get ordered stops with the given route ID.
* Returns an empty array if no ordered stops found.
* @param routeId
*/
getOrderedStopsByRouteId(routeId: string): Promise<IOrderedStop[]>;
}

View File

@@ -1,35 +0,0 @@
// If types match closely, we can use TypeScript "casting"
// to convert from data repo to GraphQL schema
import { GetterRepository } from "./GetterRepository";
import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
/**
* GetterRepository interface for data derived from Passio API.
* The repository is not designed to have write locks in place.
* Objects passed from/to the repository should be treated
* as disposable.
*/
export interface GetterSetterRepository extends GetterRepository {
// Setter methods
addOrUpdateSystem(system: ISystem): Promise<void>;
addOrUpdateRoute(route: IRoute): Promise<void>;
addOrUpdateShuttle(shuttle: IShuttle): Promise<void>;
addOrUpdateStop(stop: IStop): Promise<void>;
addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void>;
addOrUpdateEta(eta: IEta): Promise<void>;
removeSystemIfExists(systemId: string): Promise<ISystem | null>;
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>;
clearSystemData(): Promise<void>;
clearRouteData(): Promise<void>;
clearShuttleData(): Promise<void>;
clearStopData(): Promise<void>;
clearOrderedStopData(): Promise<void>;
clearEtaData(): Promise<void>;
}

View File

@@ -1,212 +0,0 @@
import { GetterSetterRepository } from "./GetterSetterRepository";
import { IEntityWithId, IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
/**
* 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 UnoptimizedInMemoryRepository implements GetterSetterRepository {
private systems: ISystem[] = [];
private stops: IStop[] = [];
private routes: IRoute[] = [];
private shuttles: IShuttle[] = [];
private etas: IEta[] = [];
private orderedStops: IOrderedStop[] = [];
public async getSystems() {
return this.systems;
}
public async getSystemById(systemId: string) {
return this.findEntityById(systemId, this.systems);
}
public async getStopsBySystemId(systemId: string) {
return this.stops.filter(stop => stop.systemId === systemId);
}
public async getStopById(stopId: string) {
return this.findEntityById(stopId, this.stops);
}
public async getRoutesBySystemId(systemId: string) {
return this.routes.filter(route => route.systemId === systemId);
}
public async getRouteById(routeId: string) {
return this.findEntityById(routeId, this.routes);
}
public async getShuttlesBySystemId(systemId: string) {
return this.shuttles.filter(shuttle => shuttle.systemId === systemId);
}
public async getShuttlesByRouteId(routeId: string) {
return this.shuttles.filter(shuttle => shuttle.routeId === routeId);
}
public async getShuttleById(shuttleId: string) {
return this.findEntityById(shuttleId, this.shuttles);
}
public async getEtasForShuttleId(shuttleId: string) {
return this.etas.filter(eta => eta.shuttleId === shuttleId);
}
public async getEtasForStopId(stopId: string) {
return this.etas.filter(eta => eta.stopId === stopId);
}
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)
}
public async getOrderedStopsByStopId(stopId: string) {
return this.orderedStops.filter((value) => value.stopId === stopId);
}
public async getOrderedStopsByRouteId(routeId: string) {
return this.orderedStops.filter((value) => value.routeId === routeId);
}
private findEntityById<T extends IEntityWithId>(entityId: string, arrayToSearchIn: T[]) {
return this.findEntityByMatcher((value) => value.id === entityId, arrayToSearchIn);
}
private findEntityByMatcher<T>(callback: (value: T) => boolean, arrayToSearchIn: T[]): T | null {
const entity = arrayToSearchIn.find(callback);
if (!entity) {
return null;
}
return entity;
}
public async addOrUpdateSystem(system: ISystem): Promise<void> {
const index = this.systems.findIndex((s) => s.id === system.id);
if (index !== -1) {
this.systems[index] = system; // Update existing
} else {
this.systems.push(system); // Add new
}
}
public async addOrUpdateRoute(route: IRoute): Promise<void> {
const index = this.routes.findIndex((r) => r.id === route.id);
if (index !== -1) {
this.routes[index] = route;
} else {
this.routes.push(route);
}
}
public async addOrUpdateShuttle(shuttle: IShuttle): Promise<void> {
const index = this.shuttles.findIndex((s) => s.id === shuttle.id);
if (index !== -1) {
this.shuttles[index] = shuttle;
} else {
this.shuttles.push(shuttle);
}
}
public async addOrUpdateStop(stop: IStop): Promise<void> {
const index = this.stops.findIndex((s) => s.id === stop.id);
if (index !== -1) {
this.stops[index] = stop;
} else {
this.stops.push(stop);
}
}
public async addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void> {
const index = this.orderedStops.findIndex((value) => value.stopId === orderedStop.stopId && value.routeId === orderedStop.routeId);
if (index !== -1) {
this.orderedStops[index] = orderedStop;
} else {
this.orderedStops.push(orderedStop);
}
}
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;
} else {
this.etas.push(eta);
}
}
private async removeEntityByMatcherIfExists<T>(callback: (value: T) => boolean, arrayToSearchIn: T[]) {
const index = arrayToSearchIn.findIndex(callback);
if (index > -1) {
const entityToReturn = arrayToSearchIn[index];
arrayToSearchIn.splice(index, 1);
return entityToReturn;
}
return null;
}
private async removeEntityByIdIfExists<T extends IEntityWithId>(entityId: string, arrayToSearchIn: T[]) {
return await this.removeEntityByMatcherIfExists((value) => value.id === entityId, arrayToSearchIn);
}
public async removeSystemIfExists(systemId: string): Promise<ISystem | null> {
return await this.removeEntityByIdIfExists(systemId, this.systems);
}
public async removeRouteIfExists(routeId: string): Promise<IRoute | null> {
return await this.removeEntityByIdIfExists(routeId, this.routes);
}
public async removeShuttleIfExists(shuttleId: string): Promise<IShuttle | null> {
return await this.removeEntityByIdIfExists(shuttleId, this.shuttles);
}
public async removeStopIfExists(stopId: string): Promise<IStop | null> {
return await this.removeEntityByIdIfExists(stopId, this.stops);
}
public async removeOrderedStopIfExists(stopId: string, routeId: string): Promise<IOrderedStop | null> {
return await this.removeEntityByMatcherIfExists((orderedStop) => {
return orderedStop.stopId === stopId
&& orderedStop.routeId === routeId
}, 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);
}
public async clearSystemData() {
this.systems = [];
}
public async clearShuttleData(): Promise<void> {
this.shuttles = [];
}
public async clearEtaData(): Promise<void> {
this.etas = [];
}
public async clearOrderedStopData(): Promise<void> {
this.orderedStops = [];
}
public async clearRouteData(): Promise<void> {
this.routes = [];
}
public async clearStopData(): Promise<void> {
this.stops = [];
}
}

View File

@@ -0,0 +1,144 @@
import {
Listener,
NotificationEvent,
NotificationLookupArguments,
NotificationRepository,
ScheduledNotification
} from "./NotificationRepository";
import { TupleKey } from "../../types/TupleKey";
type DeviceIdSecondsThresholdAssociation = { [key: string]: number };
export class InMemoryNotificationRepository implements NotificationRepository {
/**
* An object of device ID arrays to deliver notifications to.
* The key should be a combination of the shuttle ID and
* stop ID, which can be generated using `TupleKey`.
* The value is a dictionary of the device ID to the stored seconds threshold.
* @private
*/
private deviceIdsToDeliverTo: { [key: string]: DeviceIdSecondsThresholdAssociation } = {}
private listeners: Listener[] = [];
constructor() {
this.getAllNotificationsForShuttleAndStopId = this.getAllNotificationsForShuttleAndStopId.bind(this);
this.getSecondsThresholdForNotificationIfExists = this.getSecondsThresholdForNotificationIfExists.bind(this);
this.deleteNotificationIfExists = this.deleteNotificationIfExists.bind(this);
this.addOrUpdateNotification = this.addOrUpdateNotification.bind(this);
this.isNotificationScheduled = this.isNotificationScheduled.bind(this);
this.subscribeToNotificationChanges = this.subscribeToNotificationChanges.bind(this);
this.unsubscribeFromNotificationChanges = this.unsubscribeFromNotificationChanges.bind(this);
}
async getAllNotificationsForShuttleAndStopId(shuttleId: string, stopId: string) {
const tuple = new TupleKey(shuttleId, stopId);
if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) {
return [];
}
return Object.keys(this.deviceIdsToDeliverTo[tuple.toString()])
.map((deviceId) => {
return {
shuttleId,
stopId,
deviceId,
secondsThreshold: this.deviceIdsToDeliverTo[tuple.toString()][deviceId]
}
});
}
async getSecondsThresholdForNotificationIfExists({
shuttleId,
stopId,
deviceId
}: NotificationLookupArguments) {
const tuple = new TupleKey(shuttleId, stopId);
if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) {
return null;
}
return this.deviceIdsToDeliverTo[tuple.toString()][deviceId];
}
async isNotificationScheduled(lookupArguments: NotificationLookupArguments): Promise<boolean> {
const threshold = await this.getSecondsThresholdForNotificationIfExists(lookupArguments);
return threshold !== null;
}
async addOrUpdateNotification({
shuttleId,
stopId,
deviceId,
secondsThreshold
}: ScheduledNotification) {
const tuple = new TupleKey(shuttleId, stopId);
if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) {
this.deviceIdsToDeliverTo[tuple.toString()] = {};
}
this.deviceIdsToDeliverTo[tuple.toString()][deviceId] = secondsThreshold;
this.listeners.forEach((listener: Listener) => {
const event: NotificationEvent = {
event: 'addOrUpdate',
notification: {
shuttleId,
stopId,
deviceId,
secondsThreshold
},
}
listener(event);
})
}
async deleteNotificationIfExists({
deviceId,
shuttleId,
stopId
}: NotificationLookupArguments) {
const tupleKey = new TupleKey(shuttleId, stopId);
if (
this.deviceIdsToDeliverTo[tupleKey.toString()] === undefined
|| !(deviceId in this.deviceIdsToDeliverTo[tupleKey.toString()])
) {
return;
}
const secondsThreshold = this.deviceIdsToDeliverTo[tupleKey.toString()][deviceId];
delete this.deviceIdsToDeliverTo[tupleKey.toString()][deviceId];
if (Object.keys(this.deviceIdsToDeliverTo[tupleKey.toString()]).length === 0) {
// no more device IDs remaining for this key combination
delete this.deviceIdsToDeliverTo[tupleKey.toString()];
}
this.listeners.forEach((listener) => {
const event: NotificationEvent = {
event: 'delete',
notification: {
deviceId,
shuttleId,
stopId,
secondsThreshold
}
}
listener(event);
})
}
public subscribeToNotificationChanges(listener: Listener): void {
const index = this.listeners.findIndex((existingListener) => existingListener == listener);
if (index < 0) {
this.listeners.push(listener);
}
}
public unsubscribeFromNotificationChanges(listener: Listener): void {
const index = this.listeners.findIndex((existingListener) => existingListener == listener);
if (index >= 0) {
this.listeners.splice(index, 1);
}
}
}

View File

@@ -0,0 +1,33 @@
export interface NotificationLookupArguments {
deviceId: string;
shuttleId: string;
stopId: string;
}
export interface ScheduledNotification extends NotificationLookupArguments {
/**
* Value which specifies the ETA of the shuttle for when
* the notification should fire.
* For example, a secondsThreshold of 180 would mean that the notification
* fires when the ETA drops below 3 minutes.
*/
secondsThreshold: number;
}
export type Listener = ((event: NotificationEvent) => any);
export interface NotificationEvent {
notification: ScheduledNotification,
event: 'delete' | 'addOrUpdate'
}
export interface NotificationRepository {
getAllNotificationsForShuttleAndStopId(shuttleId: string, stopId: string): Promise<ScheduledNotification[]>;
getSecondsThresholdForNotificationIfExists(lookupArguments: NotificationLookupArguments): Promise<number | null>;
isNotificationScheduled(lookupArguments: NotificationLookupArguments): Promise<boolean>;
addOrUpdateNotification(notification: ScheduledNotification): Promise<void>;
deleteNotificationIfExists(lookupArguments: NotificationLookupArguments): Promise<void>;
subscribeToNotificationChanges(listener: Listener): void;
unsubscribeFromNotificationChanges(listener: Listener): void;
}

View File

@@ -0,0 +1,113 @@
import { TupleKey } from '../../types/TupleKey';
import {
Listener,
NotificationEvent,
NotificationLookupArguments,
NotificationRepository,
ScheduledNotification
} from "./NotificationRepository";
import { BaseRedisRepository } from "../BaseRedisRepository";
export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository {
private notificationListeners: Listener[] = [];
private readonly NOTIFICATION_KEY_PREFIX = 'notification:';
private getNotificationKey = (shuttleId: string, stopId: string): string => {
const tuple = new TupleKey(shuttleId, stopId);
return `${this.NOTIFICATION_KEY_PREFIX}${tuple.toString()}`;
};
public addOrUpdateNotification = async (notification: ScheduledNotification): Promise<void> => {
const { shuttleId, stopId, deviceId, secondsThreshold } = notification;
const key = this.getNotificationKey(shuttleId, stopId);
await this.redisClient.hSet(key, deviceId, secondsThreshold.toString());
this.notificationListeners.forEach((listener: Listener) => {
const event: NotificationEvent = {
event: 'addOrUpdate',
notification
};
listener(event);
});
};
public deleteNotificationIfExists = async (lookupArguments: NotificationLookupArguments): Promise<void> => {
const { shuttleId, stopId, deviceId } = lookupArguments;
const key = this.getNotificationKey(shuttleId, stopId);
const secondsThreshold = await this.redisClient.hGet(key, deviceId);
if (secondsThreshold) {
await this.redisClient.hDel(key, deviceId);
// Check if hash is empty and delete it if so
const remainingFields = await this.redisClient.hLen(key);
if (remainingFields === 0) {
await this.redisClient.del(key);
}
this.notificationListeners.forEach((listener) => {
const event: NotificationEvent = {
event: 'delete',
notification: {
deviceId,
shuttleId,
stopId,
secondsThreshold: parseInt(secondsThreshold)
}
};
listener(event);
});
}
};
public getAllNotificationsForShuttleAndStopId = async (
shuttleId: string,
stopId: string
): Promise<ScheduledNotification[]> => {
const key = this.getNotificationKey(shuttleId, stopId);
const allNotifications = await this.redisClient.hGetAll(key);
return Object.entries(allNotifications).map(([deviceId, secondsThreshold]) => ({
shuttleId,
stopId,
deviceId,
secondsThreshold: parseInt(secondsThreshold)
}));
};
public getSecondsThresholdForNotificationIfExists = async (
lookupArguments: NotificationLookupArguments
): Promise<number | null> => {
const { shuttleId, stopId, deviceId } = lookupArguments;
const key = this.getNotificationKey(shuttleId, stopId);
const threshold = await this.redisClient.hGet(key, deviceId);
return threshold ? parseInt(threshold) : null;
};
public isNotificationScheduled = async (
lookupArguments: NotificationLookupArguments
): Promise<boolean> => {
const threshold = await this.getSecondsThresholdForNotificationIfExists(lookupArguments);
return threshold !== null;
};
public subscribeToNotificationChanges = (listener: Listener): void => {
const index = this.notificationListeners.findIndex(
(existingListener) => existingListener === listener
);
if (index < 0) {
this.notificationListeners.push(listener);
}
};
public unsubscribeFromNotificationChanges = (listener: Listener): void => {
const index = this.notificationListeners.findIndex(
(existingListener) => existingListener === listener
);
if (index >= 0) {
this.notificationListeners.splice(index, 1);
}
};
}

View File

@@ -0,0 +1,218 @@
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";
interface RepositoryHolder {
name: string;
factory(): Promise<NotificationRepository>,
teardown(): Promise<void>,
}
class InMemoryRepositoryHolder implements RepositoryHolder {
name = 'InMemoryNotificationRepository';
factory = async () => {
return new InMemoryNotificationRepository();
}
teardown = async () => {}
}
class RedisNotificationRepositoryHolder implements RepositoryHolder {
repo: RedisNotificationRepository | undefined;
redisClient: RedisClientType | undefined;
name = 'RedisNotificationRepository';
factory = async () => {
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.redisClient) {
await this.redisClient.flushAll();
await this.redisClient.disconnect();
}
}
}
const repositoryImplementations = [
new InMemoryRepositoryHolder(),
new RedisNotificationRepositoryHolder(),
]
describe.each(repositoryImplementations)('$name', (holder) => {
let repo: NotificationRepository;
beforeEach(async () => {
repo = await holder.factory();
});
afterEach(async () => {
await holder.teardown();
})
const notification = {
deviceId: "device1",
shuttleId: "shuttle1",
stopId: "stop1",
secondsThreshold: 180
};
describe("getAllNotificationsForShuttleAndStopId", () => {
it("gets notifications correctly", async () => {
await repo.addOrUpdateNotification(notification);
const result = await repo.getAllNotificationsForShuttleAndStopId("shuttle1", "stop1");
expect(result).toHaveLength(1);
expect(result[0]).toEqual(notification);
});
it("returns empty array if no notifications", async () => {
const result = await repo.getAllNotificationsForShuttleAndStopId("shuttle1", "stop1");
expect(result).toEqual([]);
});
});
describe("getSecondsThresholdForNotificationIfExists", () => {
it("gets the seconds threshold if exists", async () => {
await repo.addOrUpdateNotification(notification);
const result = await repo.getSecondsThresholdForNotificationIfExists({
deviceId: "device1",
shuttleId: "shuttle1",
stopId: "stop1"
});
expect(result).toBe(180);
});
it("returns null if there is no seconds threshold", async () => {
const result = await repo.getSecondsThresholdForNotificationIfExists({
deviceId: "device1",
shuttleId: "shuttle1",
stopId: "stop1"
});
expect(result).toBeNull();
});
});
describe("addOrUpdateNotification", () => {
// Add/get flow is covered in getAllNotificationsForShuttleAndStopId
it("updates the seconds threshold if the notification exists already", async () => {
await repo.addOrUpdateNotification(notification);
await repo.addOrUpdateNotification({...notification, secondsThreshold: 300});
const result = await repo.getSecondsThresholdForNotificationIfExists({
deviceId: "device1",
shuttleId: "shuttle1",
stopId: "stop1"
});
expect(result).toBe(300);
});
});
describe("deleteNotificationIfExists", () => {
it("deletes the notification", async () => {
await repo.addOrUpdateNotification(notification);
await repo.deleteNotificationIfExists(notification);
const result = await repo.getAllNotificationsForShuttleAndStopId("shuttle1", "stop1");
expect(result).toHaveLength(0);
});
it("does nothing if there's no notification", async () => {
await expect(repo.deleteNotificationIfExists({
deviceId: "device1",
shuttleId: "shuttle1",
stopId: "stop1"
})).resolves.not.toThrow();
});
});
describe("subscribeToNotificationChanges", () => {
it("calls subscribers when something is added", async () => {
const mockCallback = jest.fn();
repo.subscribeToNotificationChanges(mockCallback);
await repo.addOrUpdateNotification(notification);
const expectedEvent: NotificationEvent = {
event: 'addOrUpdate',
notification,
}
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(expectedEvent);
});
it("calls subscribers when something is updated", async () => {
const mockCallback = jest.fn();
repo.subscribeToNotificationChanges(mockCallback);
await repo.addOrUpdateNotification(notification);
const updatedNotification = {
...notification,
secondsThreshold: notification.secondsThreshold + 60,
};
await repo.addOrUpdateNotification(updatedNotification);
const expectedEvent: NotificationEvent = {
event: 'addOrUpdate',
notification,
}
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith(expectedEvent);
});
it("calls subscribers when something is deleted", async () => {
await repo.addOrUpdateNotification(notification);
const mockCallback = jest.fn();
repo.subscribeToNotificationChanges(mockCallback);
await repo.deleteNotificationIfExists(notification);
expect(mockCallback).toHaveBeenCalledTimes(1);
const expectedEvent: NotificationEvent = {
event: 'delete',
notification,
};
expect(mockCallback).toHaveBeenCalledWith(expectedEvent);
});
});
describe("unsubscribeFromNotificationChanges", () => {
it("stops calling subscribers when unsubscribed", async () => {
const mockCallback = jest.fn();
repo.subscribeToNotificationChanges(mockCallback);
await repo.addOrUpdateNotification(notification);
repo.unsubscribeFromNotificationChanges(mockCallback);
await repo.deleteNotificationIfExists(notification);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
});
describe("isNotificationScheduled", () => {
it("returns true if the notification is in the repo", async () => {
await repo.addOrUpdateNotification(notification);
const result = await repo.isNotificationScheduled(notification);
expect(result).toBe(true);
});
it("returns false if the notification isn't in the repo", async () => {
const result = await repo.isNotificationScheduled(notification);
expect(result).toBe(false);
})
});
});

View File

@@ -0,0 +1,161 @@
import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository";
import {
IParkingStructure,
IParkingStructureTimestampRecord
} from "../../entities/ParkingRepositoryEntities";
import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository";
import { CircularQueue } from "../../types/CircularQueue";
import { PARKING_LOGGING_INTERVAL_MS } from "../../environment";
// If every 10 minutes, two weeks of data (6x per hour * 24x per day * 7x per week * 2)
export const MAX_NUM_ENTRIES = 2016;
export type ParkingStructureID = string;
export class InMemoryParkingRepository implements ParkingGetterSetterRepository {
private dataLastAdded: Map<ParkingStructureID, Date> = new Map();
private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS;
constructor(
private structures: Map<ParkingStructureID, IParkingStructure> = new Map(),
private historicalData: Map<ParkingStructureID, CircularQueue<IParkingStructureTimestampRecord>> = new Map(),
) {
}
addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise<void> => {
this.structures.set(structure.id, { ...structure });
await this.addHistoricalDataForStructure(structure);
};
private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise<void> => {
const now = Date.now();
const lastAdded = this.dataLastAdded.get(structure.id);
if (this.shouldLogHistoricalData(lastAdded, now)) {
const timestampRecord = this.createTimestampRecord(structure, now);
this.ensureHistoricalDataExists(structure.id);
this.addRecordToHistoricalData(structure.id, timestampRecord);
this.dataLastAdded.set(structure.id, new Date(now));
}
};
clearParkingStructureData = async (): Promise<void> => {
this.structures.clear();
this.historicalData.clear();
this.dataLastAdded.clear();
};
getParkingStructureById = async (id: string): Promise<IParkingStructure | null> => {
const structure = this.structures.get(id);
return structure ? { ...structure } : null;
};
getParkingStructures = async (): Promise<IParkingStructure[]> => Array.from(this.structures.values()).map(structure => ({...structure}));
removeParkingStructureIfExists = async (id: string): Promise<IParkingStructure | null> => {
const structure = this.structures.get(id);
if (structure) {
this.structures.delete(id);
this.historicalData.delete(id);
this.dataLastAdded.delete(id);
return { ...structure };
}
return null;
};
getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise<HistoricalParkingAverageQueryResult[]> => {
const queue = this.historicalData.get(id);
if (!queue || queue.size() === 0) {
return [];
}
const records = this.extractRecordsFromQueue(queue);
return this.calculateAveragesFromRecords(records, options);
};
private shouldLogHistoricalData = (lastAdded: Date | undefined, currentTime: number): boolean => {
return !lastAdded || (currentTime - lastAdded.getTime()) >= this.loggingIntervalMs;
};
private createTimestampRecord = (structure: IParkingStructure, timestampMs: number): IParkingStructureTimestampRecord => ({
id: structure.id,
spotsAvailable: structure.spotsAvailable,
timestampMs: new Date(timestampMs),
});
private ensureHistoricalDataExists = (structureId: string): void => {
if (!this.historicalData.has(structureId)) {
this.historicalData.set(structureId, new CircularQueue<IParkingStructureTimestampRecord>(MAX_NUM_ENTRIES));
}
};
private addRecordToHistoricalData = (structureId: string, record: IParkingStructureTimestampRecord): void => {
const sortingCallback = (a: IParkingStructureTimestampRecord, b: IParkingStructureTimestampRecord) => a.timestampMs.getTime() - b.timestampMs.getTime();
this.historicalData.get(structureId)?.appendWithSorting(record, sortingCallback);
};
private extractRecordsFromQueue = (queue: CircularQueue<IParkingStructureTimestampRecord>): IParkingStructureTimestampRecord[] => {
const records: IParkingStructureTimestampRecord[] = [];
for (let i = 0; i < queue.size(); i++) {
const record = queue.get(i);
if (record) {
records.push(record);
}
}
return records;
};
private calculateAveragesFromRecords = (
records: IParkingStructureTimestampRecord[],
options: HistoricalParkingAverageFilterArguments
): HistoricalParkingAverageQueryResult[] => {
const results: HistoricalParkingAverageQueryResult[] = [];
const { from, to, intervalMs } = options;
let currentIntervalStart = from.getTime();
const endTime = to.getTime();
while (currentIntervalStart < endTime) {
const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endTime);
const recordsInInterval = this.getRecordsInTimeRange(records, currentIntervalStart, currentIntervalEnd);
if (recordsInInterval.length > 0) {
const averageResult = this.calculateAverageForInterval(currentIntervalStart, currentIntervalEnd, recordsInInterval);
results.push(averageResult);
}
currentIntervalStart = currentIntervalEnd;
}
return results;
};
private getRecordsInTimeRange = (
records: IParkingStructureTimestampRecord[],
startMs: number,
endMs: number
): IParkingStructureTimestampRecord[] => {
return records.filter(record =>
record.timestampMs.getTime() >= startMs && record.timestampMs.getTime() < endMs
);
};
private calculateAverageForInterval = (
fromMs: number,
toMs: number,
records: IParkingStructureTimestampRecord[]
): HistoricalParkingAverageQueryResult => {
const totalSpotsAvailable = records.reduce((sum, record) => sum + record.spotsAvailable, 0);
const averageSpotsAvailable = totalSpotsAvailable / records.length;
return {
from: new Date(fromMs),
to: new Date(toMs),
averageSpotsAvailable
};
};
setLoggingInterval = (intervalMs: number): void => {
this.loggingIntervalMs = intervalMs;
};
}

View File

@@ -0,0 +1,26 @@
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
export interface HistoricalParkingAverageFilterArguments {
from: Date;
to: Date;
intervalMs: number;
}
export interface HistoricalParkingAverageQueryResult {
from: Date;
to: Date;
averageSpotsAvailable: number;
}
export interface ParkingGetterRepository {
getParkingStructures(): Promise<IParkingStructure[]>;
getParkingStructureById(id: string): Promise<IParkingStructure | null>;
/**
* Get historical averages of parking structure data using the filtering options.
* @param id
* @param options
*/
getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageFilterArguments): Promise<HistoricalParkingAverageQueryResult[]>;
}

View File

@@ -0,0 +1,12 @@
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
import { ParkingGetterRepository } from "./ParkingGetterRepository";
export interface ParkingGetterSetterRepository extends ParkingGetterRepository {
addOrUpdateParkingStructure(structure: IParkingStructure): Promise<void>;
removeParkingStructureIfExists(id: string): Promise<IParkingStructure | null>;
clearParkingStructureData(): Promise<void>;
setLoggingInterval(intervalMs: number): void;
}

View File

@@ -0,0 +1,204 @@
import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository";
import { IParkingStructure } from "../../entities/ParkingRepositoryEntities";
import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository";
import { BaseRedisRepository } from "../BaseRedisRepository";
import { PARKING_LOGGING_INTERVAL_MS } from "../../environment";
export type ParkingStructureID = string;
export class RedisParkingRepository extends BaseRedisRepository implements ParkingGetterSetterRepository {
private dataLastAdded: Map<ParkingStructureID, Date> = new Map();
private loggingIntervalMs = PARKING_LOGGING_INTERVAL_MS;
addOrUpdateParkingStructure = async (structure: IParkingStructure): Promise<void> => {
const keys = this.createRedisKeys(structure.id);
await this.redisClient.hSet(keys.structure, this.createRedisHashFromStructure(structure));
await this.addHistoricalDataForStructure(structure);
};
private addHistoricalDataForStructure = async (structure: IParkingStructure): Promise<void> => {
const now = Date.now();
const lastAdded = this.dataLastAdded.get(structure.id);
if (this.shouldLogHistoricalData(lastAdded, now)) {
const keys = this.createRedisKeys(structure.id);
await this.addTimeSeriesDataPoint(keys.timeSeries, now, structure.spotsAvailable, structure.id);
this.dataLastAdded.set(structure.id, new Date(now));
}
};
clearParkingStructureData = async (): Promise<void> => {
const structureKeys = await this.redisClient.keys('parking:structure:*');
const timeSeriesKeys = await this.redisClient.keys('parking:timeseries:*');
const allKeys = [...structureKeys, ...timeSeriesKeys];
if (allKeys.length > 0) {
await this.redisClient.del(allKeys);
}
this.dataLastAdded.clear();
};
getParkingStructureById = async (id: string): Promise<IParkingStructure | null> => {
const keys = this.createRedisKeys(id);
const data = await this.redisClient.hGetAll(keys.structure);
if (Object.keys(data).length === 0) {
return null;
}
return this.createStructureFromRedisData(data);
};
getParkingStructures = async (): Promise<IParkingStructure[]> => {
const keys = await this.redisClient.keys('parking:structure:*');
const structures: IParkingStructure[] = [];
for (const key of keys) {
const data = await this.redisClient.hGetAll(key);
if (Object.keys(data).length > 0) {
structures.push(this.createStructureFromRedisData(data));
}
}
return structures;
};
removeParkingStructureIfExists = async (id: string): Promise<IParkingStructure | null> => {
const structure = await this.getParkingStructureById(id);
if (structure) {
const keys = this.createRedisKeys(id);
await this.redisClient.del([keys.structure, keys.timeSeries]);
this.dataLastAdded.delete(id);
return structure;
}
return null;
};
getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise<HistoricalParkingAverageQueryResult[]> => {
return this.calculateAveragesFromRecords(id, options);
};
private createRedisKeys = (structureId: string) => ({
structure: `parking:structure:${structureId}`,
timeSeries: `parking:timeseries:${structureId}`
});
private createRedisHashFromStructure = (structure: IParkingStructure): Record<string, string> => ({
id: structure.id,
name: structure.name,
address: structure.address,
capacity: structure.capacity.toString(),
spotsAvailable: structure.spotsAvailable.toString(),
latitude: structure.coordinates.latitude.toString(),
longitude: structure.coordinates.longitude.toString(),
updatedTime: structure.updatedTime.toISOString()
});
private createStructureFromRedisData = (data: Record<string, string>): IParkingStructure => ({
id: data.id,
name: data.name,
address: data.address,
capacity: parseInt(data.capacity),
spotsAvailable: parseInt(data.spotsAvailable),
coordinates: {
latitude: parseFloat(data.latitude),
longitude: parseFloat(data.longitude)
},
updatedTime: new Date(data.updatedTime)
});
private shouldLogHistoricalData = (lastAdded: Date | undefined, currentTime: number): boolean => {
return !lastAdded || (currentTime - lastAdded.getTime()) >= this.loggingIntervalMs;
};
private addTimeSeriesDataPoint = async (timeSeriesKey: string, timestamp: number, value: number, structureId: string): Promise<void> => {
try {
await this.redisClient.sendCommand([
'TS.ADD',
timeSeriesKey,
timestamp.toString(),
value.toString(),
'LABELS',
'structureId',
structureId
]);
} catch (error) {
await this.createTimeSeriesAndAddDataPoint(timeSeriesKey, timestamp, value, structureId);
}
};
private createTimeSeriesAndAddDataPoint = async (timeSeriesKey: string, timestamp: number, value: number, structureId: string): Promise<void> => {
try {
await this.redisClient.sendCommand([
'TS.CREATE',
timeSeriesKey,
'RETENTION',
'2678400000', // one month
'LABELS',
'structureId',
structureId
]);
await this.redisClient.sendCommand([
'TS.ADD',
timeSeriesKey,
timestamp.toString(),
value.toString()
]);
} catch (createError) {
await this.redisClient.sendCommand([
'TS.ADD',
timeSeriesKey,
timestamp.toString(),
value.toString()
]);
}
};
private calculateAveragesFromRecords = async (
id: string,
options: HistoricalParkingAverageFilterArguments
): Promise<HistoricalParkingAverageQueryResult[]> => {
const keys = this.createRedisKeys(id);
const { from, to, intervalMs } = options;
const results: HistoricalParkingAverageQueryResult[] = [];
let currentIntervalStart = from.getTime();
const endTime = to.getTime();
while (currentIntervalStart < endTime) {
const currentIntervalEnd = Math.min(currentIntervalStart + intervalMs, endTime);
try {
const aggregationResult = await this.redisClient.sendCommand([
'TS.RANGE',
keys.timeSeries,
currentIntervalStart.toString(),
currentIntervalEnd.toString(),
'AGGREGATION',
'AVG',
intervalMs.toString()
]) as [string, string][];
if (aggregationResult && aggregationResult.length > 0) {
const [, averageValue] = aggregationResult[0];
results.push({
from: new Date(currentIntervalStart),
to: new Date(currentIntervalEnd),
averageSpotsAvailable: parseFloat(averageValue)
});
}
} catch (error) {
// If Redis aggregation fails, skip this interval
}
currentIntervalStart = currentIntervalEnd;
}
return results;
};
setLoggingInterval = (intervalMs: number): void => {
this.loggingIntervalMs = intervalMs;
};
}

View File

@@ -0,0 +1,205 @@
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 { ParkingGetterSetterRepository } from "../ParkingGetterSetterRepository";
import { RedisParkingRepository } from "../RedisParkingRepository";
import { HistoricalParkingAverageFilterArguments } from "../ParkingGetterRepository";
import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder";
class InMemoryParkingRepositoryHolder implements RepositoryHolder<ParkingGetterSetterRepository> {
name = 'InMemoryParkingRepository';
factory = async () => {
return new InMemoryParkingRepository();
};
teardown = async () => {};
}
class RedisParkingRepositoryHolder implements RepositoryHolder<ParkingGetterSetterRepository> {
repo: RedisParkingRepository | undefined;
redisClient: RedisClientType | undefined;
name = 'RedisParkingRepository';
factory = async () => {
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.redisClient) {
await this.redisClient.flushAll();
await this.redisClient.disconnect();
}
};
}
const repositoryImplementations = [
new InMemoryParkingRepositoryHolder(),
new RedisParkingRepositoryHolder(),
];
describe.each(repositoryImplementations)('$name', (holder) => {
let repository: ParkingGetterSetterRepository;
const testStructure: IParkingStructure = {
coordinates: {
latitude: 33.794795,
longitude: -117.850807,
},
spotsAvailable: 0,
id: "1",
name: "Anderson Parking Structure",
capacity: 100,
address: "300 E Walnut Ave, Orange, CA 92867",
updatedTime: new Date(),
};
beforeEach(async () => {
repository = await holder.factory();
jest.useRealTimers();
});
afterEach(async () => {
await holder.teardown();
jest.useRealTimers();
});
describe("addOrUpdateParkingStructure", () => {
it("should add a new parking structure", async () => {
await repository.addOrUpdateParkingStructure(testStructure);
const result = await repository.getParkingStructureById(testStructure.id);
expect(result).toEqual(testStructure);
});
it("should update existing parking structure", async () => {
await repository.addOrUpdateParkingStructure(testStructure);
const updatedStructure = { ...testStructure, name: "Updated Garage" };
await repository.addOrUpdateParkingStructure(updatedStructure);
const result = await repository.getParkingStructureById(testStructure.id);
expect(result).toEqual(updatedStructure);
});
});
describe("removeParkingStructureIfExists", () => {
it("should remove existing parking structure and return it", async () => {
await repository.addOrUpdateParkingStructure(testStructure);
const removed = await repository.removeParkingStructureIfExists(testStructure.id);
expect(removed).toEqual(testStructure);
const result = await repository.getParkingStructureById(testStructure.id);
expect(result).toBeNull();
});
it("should return null when removing non-existent structure", async () => {
const result = await repository.removeParkingStructureIfExists("non-existent");
expect(result).toBeNull();
});
});
describe("clearParkingStructureData", () => {
it("should remove all parking structures", async () => {
const structures = [
testStructure,
{ ...testStructure, id: "test-id-2", name: "Second Garage" }
];
for (const structure of structures) {
await repository.addOrUpdateParkingStructure(structure);
}
await repository.clearParkingStructureData();
const result = await repository.getParkingStructures();
expect(result).toHaveLength(0);
});
});
describe("getParkingStructures", () => {
it("should return empty array when no structures exist", async () => {
const result = await repository.getParkingStructures();
expect(result).toEqual([]);
});
it("should return all added structures", async () => {
const structures = [
testStructure,
{ ...testStructure, id: "test-id-2", name: "Second Garage" }
];
for (const structure of structures) {
await repository.addOrUpdateParkingStructure(structure);
}
const result = await repository.getParkingStructures();
expect(result).toHaveLength(2);
expect(result).toEqual(expect.arrayContaining(structures));
});
});
describe("getParkingStructureById", () => {
it("should return null for non-existent structure", async () => {
const result = await repository.getParkingStructureById("non-existent");
expect(result).toBeNull();
});
it("should return structure by id", async () => {
await repository.addOrUpdateParkingStructure(testStructure);
const result = await repository.getParkingStructureById(testStructure.id);
expect(result).toEqual(testStructure);
});
});
describe("getHistoricalAveragesOfParkingStructureCounts", () => {
it("should return empty array for non-existent structure or no data", async () => {
const options: HistoricalParkingAverageFilterArguments = {
from: new Date(1000),
to: new Date(2000),
intervalMs: 500
};
expect(await repository.getHistoricalAveragesOfParkingStructureCounts("non-existent", options)).toEqual([]);
await repository.addOrUpdateParkingStructure(testStructure);
expect(await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options)).toEqual([]);
});
it("should calculate average for one single large interval", async () => {
// Set logging interval to 0 so every update creates historical data
repository.setLoggingInterval(0);
await repository.addOrUpdateParkingStructure(testStructure);
const updates = [
{ ...testStructure, spotsAvailable: 80, updatedTime: new Date() },
{ ...testStructure, spotsAvailable: 70, updatedTime: new Date() },
{ ...testStructure, spotsAvailable: 60, updatedTime: new Date() },
];
for (let i = 0; i < updates.length; i++) {
// Ensure that different timestamps are created, even after adding the first test structure
await new Promise((resolve) => setTimeout(resolve, 200));
await repository.addOrUpdateParkingStructure(updates[i]);
}
const now = Date.now();
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
};
const result = await repository.getHistoricalAveragesOfParkingStructureCounts(testStructure.id, options);
// Should have at least some historical data
expect(result.length).toEqual(1);
if (result.length > 0) {
expect(result[0]).toHaveProperty('from');
expect(result[0]).toHaveProperty('to');
expect(result[0].from).toBeInstanceOf(Date);
expect(result[0].to).toBeInstanceOf(Date);
expect(result[0]).toHaveProperty('averageSpotsAvailable');
expect(result[0].averageSpotsAvailable).toBeCloseTo(52.5);
}
});
});
});

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

@@ -0,0 +1,119 @@
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 extends EventEmitter {
getStops(): Promise<IStop[]>;
getStopById(stopId: string): Promise<IStop | null>;
getRoutes(): Promise<IRoute[]>;
getRouteById(routeId: string): Promise<IRoute | null>;
getShuttles(): Promise<IShuttle[]>;
getShuttleById(shuttleId: string): Promise<IShuttle | null>;
getShuttlesByRouteId(routeId: string): Promise<IShuttle[]>;
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>;
/**
* Get ordered stops with the given stop ID.
* Returns an empty array if no ordered stops found.
* @param stopId
*/
getOrderedStopsByStopId(stopId: string): Promise<IOrderedStop[]>;
/**
* Get ordered stops with the given route ID.
* Returns an empty array if no ordered stops found.
* @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

@@ -0,0 +1,40 @@
// If types match closely, we can use TypeScript "casting"
// to convert from data repo to GraphQL schema
import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "./ShuttleGetterRepository";
import { IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
/**
* ShuttleGetterRepository interface for data derived from Passio API.
* The repository is not designed to have write locks in place.
* Objects passed from/to the repository should be treated
* as disposable.
*/
export interface ShuttleGetterSetterRepository extends ShuttleGetterRepository {
// Setter methods
addOrUpdateRoute(route: IRoute): Promise<void>;
addOrUpdateShuttle(shuttle: IShuttle, travelTimeTimestamp?: number, referenceCurrentTime?: Date): Promise<void>;
addOrUpdateStop(stop: IStop): Promise<void>;
addOrUpdateOrderedStop(orderedStop: IOrderedStop): 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>;
clearRouteData(): Promise<void>;
clearShuttleData(): Promise<void>;
clearStopData(): Promise<void>;
clearOrderedStopData(): 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

@@ -0,0 +1,377 @@
import EventEmitter from "node:events";
import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository";
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
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 orderedStops: IOrderedStop[] = [];
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;
}
public async getStopById(stopId: string) {
return this.findEntityById(stopId, this.stops);
}
public async getRoutes(): Promise<IRoute[]> {
return this.routes;
}
public async getRouteById(routeId: string) {
return this.findEntityById(routeId, this.routes);
}
public async getShuttles(): Promise<IShuttle[]> {
return this.shuttles;
}
public async getShuttlesByRouteId(routeId: string) {
return this.shuttles.filter(shuttle => shuttle.routeId === routeId);
}
public async getShuttleById(shuttleId: string) {
return this.findEntityById(shuttleId, this.shuttles);
}
public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string) {
return this.findEntityByMatcher<IOrderedStop>((value) => value.routeId === routeId && value.stopId === stopId, this.orderedStops)
}
public async getOrderedStopsByStopId(stopId: string) {
return this.orderedStops.filter((value) => value.stopId === stopId);
}
public async getOrderedStopsByRouteId(routeId: string) {
return this.orderedStops.filter((value) => value.routeId === routeId);
}
private findEntityById<T extends IEntityWithId>(entityId: string, arrayToSearchIn: T[]) {
return this.findEntityByMatcher((value) => value.id === entityId, arrayToSearchIn);
}
private findEntityByMatcher<T>(callback: (value: T) => boolean, arrayToSearchIn: T[]): T | null {
const entity = arrayToSearchIn.find(callback);
if (!entity) {
return null;
}
return entity;
}
public async addOrUpdateRoute(route: IRoute): Promise<void> {
const index = this.routes.findIndex((r) => r.id === route.id);
if (index !== -1) {
this.routes[index] = route;
} else {
this.routes.push(route);
}
}
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> {
const index = this.stops.findIndex((s) => s.id === stop.id);
if (index !== -1) {
this.stops[index] = stop;
} else {
this.stops.push(stop);
}
}
public async addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise<void> {
const index = this.orderedStops.findIndex((value) => value.stopId === orderedStop.stopId && value.routeId === orderedStop.routeId);
if (index !== -1) {
this.orderedStops[index] = orderedStop;
} else {
this.orderedStops.push(orderedStop);
}
}
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) {
// 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);
}
}
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[]) {
const index = arrayToSearchIn.findIndex(callback);
if (index > -1) {
const entityToReturn = arrayToSearchIn[index];
arrayToSearchIn.splice(index, 1);
return entityToReturn;
}
return null;
}
private async removeEntityByIdIfExists<T extends IEntityWithId>(entityId: string, arrayToSearchIn: T[]) {
return await this.removeEntityByMatcherIfExists((value) => value.id === entityId, arrayToSearchIn);
}
public async removeRouteIfExists(routeId: string): Promise<IRoute | null> {
return await this.removeEntityByIdIfExists(routeId, this.routes);
}
public async removeShuttleIfExists(shuttleId: string): Promise<IShuttle | null> {
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> {
return await this.removeEntityByIdIfExists(stopId, this.stops);
}
public async removeOrderedStopIfExists(stopId: string, routeId: string): Promise<IOrderedStop | null> {
return await this.removeEntityByMatcherIfExists((orderedStop) => {
return orderedStop.stopId === stopId
&& orderedStop.routeId === routeId
}, this.orderedStops);
}
private async removeShuttleLastStopIfExists(shuttleId: string) {
this.shuttleLastStopArrivals.delete(shuttleId);
}
public async clearShuttleData(): Promise<void> {
this.shuttles = [];
this.shuttlesAtStop.clear();
await this.clearShuttleLastStopData();
}
public async clearOrderedStopData(): Promise<void> {
this.orderedStops = [];
}
public async clearRouteData(): Promise<void> {
this.routes = [];
}
public async clearStopData(): Promise<void> {
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

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

@@ -4,10 +4,16 @@ import { ServerContext } from "../ServerContext";
export const EtaResolvers: Resolvers<ServerContext> = {
ETA: {
stop: async (parent, args, contextValue, info) => {
return await contextValue.repository.getStopById(parent.stopId);
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
return await system.shuttleRepository.getStopById(parent.stopId);
},
shuttle: async (parent, args, contextValue, info) => {
return await contextValue.repository.getShuttleById(parent.shuttleId);
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
return await system.shuttleRepository.getShuttleById(parent.shuttleId);
},
},
}
}

View File

@@ -0,0 +1,94 @@
import { MutationScheduleNotificationArgs, NotificationResponse, Resolvers } from "../generated/graphql";
import { ServerContext } from "../ServerContext";
import {
ETANotificationScheduler,
} from "../notifications/schedulers/ETANotificationScheduler";
import { ScheduledNotification } from "../repositories/notifications/NotificationRepository";
import { InterchangeSystem } from "../entities/InterchangeSystem";
async function temp_findMatchingSystemBasedOnShuttleId(context: ServerContext, args: Omit<MutationScheduleNotificationArgs, "input"> & {
input: NonNullable<MutationScheduleNotificationArgs["input"]>
}) {
let matchingSystem: InterchangeSystem | undefined;
await Promise.all(context.systems.map(async (system) => {
const shuttle = await system.shuttleRepository.getShuttleById(args.input.shuttleId);
// Theoretically, there should only be one
if (shuttle !== null) {
matchingSystem = system;
}
return shuttle;
}));
return matchingSystem;
}
export const MutationResolvers: Resolvers<ServerContext> = {
// TODO: Require system ID on these endpoints
Mutation: {
scheduleNotification: async (_parent, args, context, _info) => {
let matchingSystem = await temp_findMatchingSystemBasedOnShuttleId(context, args);
if (!matchingSystem) {
return {
message: "Shuttle ID doesn't exist",
success: false,
}
}
const shuttle = await matchingSystem.shuttleRepository.getShuttleById(args.input.shuttleId);
if (!shuttle) {
return {
message: "Shuttle ID doesn't exist",
success: false,
}
}
const stop = await matchingSystem.shuttleRepository.getStopById(args.input.stopId);
if (!stop) {
return {
message: "Stop ID doesn't exist",
success: false,
}
}
const notificationData: ScheduledNotification = {
...args.input,
secondsThreshold: typeof args.input.secondsThreshold === 'number'
? args.input.secondsThreshold
: ETANotificationScheduler.defaultSecondsThresholdForNotificationToFire,
}
await matchingSystem.notificationRepository.addOrUpdateNotification(notificationData);
const response: NotificationResponse = {
message: "Notification scheduled",
success: true,
data: args.input,
}
return response;
},
cancelNotification: async (_parent, args, context, _info) => {
const matchingSystem = await temp_findMatchingSystemBasedOnShuttleId(context, args);
if (!matchingSystem) {
return {
success: false,
message: "Unable to find correct system",
data: args.input,
}
}
const isScheduled = await matchingSystem.notificationRepository.isNotificationScheduled(args.input)
if (isScheduled) {
await matchingSystem.notificationRepository.deleteNotificationIfExists(args.input);
return {
success: true,
message: "Notification cancelled",
data: args.input,
}
}
return {
success: false,
message: "Notification doesn't exist"
}
},
},
}

View File

@@ -3,50 +3,66 @@ import { ServerContext } from "../ServerContext";
export const OrderedStopResolvers: Resolvers<ServerContext> = {
OrderedStop: {
nextStop: async (parent, args, contextValue, info): Promise<OrderedStop | null> => {
nextStop: async (parent, args, contextValue, _info): Promise<OrderedStop | null> => {
const routeId = parent.routeId;
const stopId = parent.stopId;
const currentOrderedStop = await contextValue.repository.getOrderedStopByRouteAndStopId(routeId, stopId);
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const currentOrderedStop = await system.shuttleRepository.getOrderedStopByRouteAndStopId(routeId, stopId);
if (!currentOrderedStop) return null;
const nextOrderedStop = currentOrderedStop.nextStop;
if (!nextOrderedStop) return null;
const nextOrderedStopObject = await contextValue.repository.getStopById(nextOrderedStop.stopId);
const nextOrderedStopObject = await system.shuttleRepository.getStopById(nextOrderedStop.stopId);
if (!nextOrderedStopObject) return null;
return {
route: parent.route,
routeId: parent.routeId,
stopId: nextOrderedStopObject.id,
systemId: system.id,
updatedTime: nextOrderedStopObject.updatedTime
}
},
previousStop: async (parent, args, contextValue, info): Promise<OrderedStop | null> => {
previousStop: async (parent, args, contextValue, _info): Promise<OrderedStop | null> => {
const routeId = parent.routeId;
const stopId = parent.stopId;
const currentOrderedStop = await contextValue.repository.getOrderedStopByRouteAndStopId(routeId, stopId);
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const currentOrderedStop = await system.shuttleRepository.getOrderedStopByRouteAndStopId(routeId, stopId);
if (!currentOrderedStop) return null;
const previousOrderedStop = currentOrderedStop.previousStop;
if (!previousOrderedStop) return null;
const previousOrderedStopObject = await contextValue.repository.getStopById(previousOrderedStop.stopId);
const previousOrderedStopObject = await system.shuttleRepository.getStopById(previousOrderedStop.stopId);
if (!previousOrderedStopObject) return null;
return {
route: parent.route,
routeId: parent.routeId,
stopId: previousOrderedStopObject.id,
systemId: system.id,
updatedTime: previousOrderedStopObject.updatedTime,
}
},
stop: async (parent, args, contextValue, info) => {
return await contextValue.repository.getStopById(parent.stopId);
stop: async (parent, args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
return await system.shuttleRepository.getStopById(parent.stopId);
},
route: async (parent, args, contextValue, info) => {
return await contextValue.repository.getRouteById(parent.routeId);
route: async (parent, args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
return await system.shuttleRepository.getRouteById(parent.routeId);
},
},
}
}

View File

@@ -0,0 +1,59 @@
import { Resolvers } from "../generated/graphql";
import { ServerContext } from "../ServerContext";
import { HistoricalParkingAverageFilterArguments } from "../repositories/parking/ParkingGetterRepository";
import { GraphQLError } from "graphql/error";
import {
PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN,
PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL
} from "../environment";
export const ParkingStructureResolvers: Resolvers<ServerContext> = {
ParkingStructure: {
historicalAverages: async (parent, args, contextValue, _info) => {
/**
* @param errorMessage
*/
function throwBadUserInputError(errorMessage: string) {
throw new GraphQLError(errorMessage, {
extensions: {
code: 'BAD_USER_INPUT',
}
});
}
const system = contextValue.findSystemById(parent.systemId);
if (!args.input?.intervalMs) {
throwBadUserInputError('No interval provided');
return null;
}
const queryArguments: HistoricalParkingAverageFilterArguments = {
from: new Date(args.input.from),
intervalMs: args.input.intervalMs,
to: new Date(args.input.to),
}
if (Number.isNaN(queryArguments.from.getTime()
|| Number.isNaN(queryArguments.to.getTime()))) {
throwBadUserInputError('One or more incorrect dates provided');
}
if (queryArguments.from.getTime() > queryArguments.to.getTime()) {
throwBadUserInputError("`from` date can't be greater than the `to` date");
}
// Limit queries for improved performance
if (queryArguments.to.getTime() - queryArguments.from.getTime() > PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN) {
throwBadUserInputError('Maximum timespan exceeded');
}
if (queryArguments.intervalMs < PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL) {
throwBadUserInputError('Provided interval is less than minimum interval');
}
const parkingAverages = await system?.parkingRepository?.getHistoricalAveragesOfParkingStructureCounts(parent.id, queryArguments);
if (!parkingAverages) {
return null;
}
return parkingAverages;
}
}
}

View File

@@ -0,0 +1,42 @@
import { Resolvers } from "../generated/graphql";
import { ServerContext } from "../ServerContext";
export const ParkingSystemResolvers: Resolvers<ServerContext> = {
ParkingSystem: {
parkingStructures: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) {
return [];
}
const parkingRepository = system.parkingRepository;
if (!parkingRepository) return [];
const parkingStructures = await parkingRepository.getParkingStructures();
return parkingStructures
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((structure) => {
return {
...structure,
systemId: parent.systemId
};
});
},
parkingStructure: async (parent, args, contextValue, _info) => {
if (!args.id) return null;
const system = contextValue.findSystemById(parent.systemId);
if (!system) {
return null;
}
const parkingRepository = system.parkingRepository;
if (!parkingRepository) return null;
const parkingStructure = await parkingRepository.getParkingStructureById(args.id);
return parkingStructure ? {
...parkingStructure,
systemId: parent.systemId,
} : null;
},
}
}

View File

@@ -1,20 +1,62 @@
import { ServerContext } from "../ServerContext";
import { Resolvers } from "../generated/graphql";
const GRAPHQL_SCHEMA_MIN_VERSION = 1;
const GRAPHQL_SCHEMA_CURRENT_VERSION = 1;
export const QueryResolvers: Resolvers<ServerContext> = {
Query: {
systems: async (parent, args, contextValue, info) => {
return await contextValue.repository.getSystems();
systems: async (_parent, args, contextValue, _info) => {
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) => {
system: async (_parent, args, contextValue, _info) => {
if (!args.id) return null;
const system = await contextValue.repository.getSystemById(args.id);
const system = contextValue.findSystemById(args.id);
if (system === null) return null;
return {
name: system.name,
id: system.id,
};
}
},
// TODO: Update the GraphQL schema to require a system ID
isNotificationScheduled: async (_parent, args, contextValue, _info) => {
const notificationData = args.input;
for (let system of contextValue.systems) {
const isScheduled = await system.notificationRepository.isNotificationScheduled(notificationData);
if (isScheduled) {
return true;
}
}
return false;
},
secondsThresholdForNotification: async (_parent, args, contextValue, _info) => {
const notificationData = args.input;
for (let system of contextValue.systems) {
const isScheduled = await system.notificationRepository.isNotificationScheduled(notificationData);
if (isScheduled) {
return await system.notificationRepository.getSecondsThresholdForNotificationIfExists(args.input);
}
}
return null;
},
schemaMinVersion: async (_parent, _args, _contextValue, _info) => {
return GRAPHQL_SCHEMA_MIN_VERSION;
},
schemaCurrentVersion: async (_parent, _args, _contextValue, _info) => {
return GRAPHQL_SCHEMA_CURRENT_VERSION;
},
},
}
}

View File

@@ -3,34 +3,67 @@ import { ServerContext } from "../ServerContext";
export const RouteResolvers: Resolvers<ServerContext> = {
Route: {
shuttles: async (parent, args, contextValue, info) => {
const shuttles = await contextValue.repository.getShuttlesByRouteId(parent.id);
shuttles: async (parent, args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
return shuttles.map(({
const shuttles = await system.shuttleRepository.getShuttlesByRouteId(parent.id);
return shuttles
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({
coordinates,
name,
id,
orientationInDegrees,
updatedTime
}) => ({
coordinates: coordinates as Coordinates,
name,
route: parent,
routeId: parent.id,
id,
orientationInDegrees,
systemId: parent.systemId,
updatedTime,
}));
},
orderedStop: async (parent, args, contextValue, info) => {
orderedStop: async (parent, args, contextValue, _info) => {
if (!args.forStopId) return null;
const orderedStop = await contextValue.repository.getOrderedStopByRouteAndStopId(parent.id, args.forStopId);
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const orderedStop = await system.shuttleRepository.getOrderedStopByRouteAndStopId(parent.id, args.forStopId);
if (!orderedStop) return null;
const stop = await contextValue.repository.getStopById(orderedStop.stopId);
const stop = await system.shuttleRepository.getStopById(orderedStop.stopId);
if (!stop) return null;
return {
stopId: args.forStopId,
routeId: parent.id,
route: parent,
systemId: system.id,
updatedTime: orderedStop.updatedTime,
}
},
orderedStops: async (parent, args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const orderedStops = await system.shuttleRepository.getOrderedStopsByRouteId(parent.id);
return orderedStops
.slice()
.sort((a, b) => a.position - b.position)
.map(({ routeId, stopId, position, systemId, updatedTime }) => ({
routeId,
stopId,
position,
systemId,
updatedTime,
}));
}
},
}
}

View File

@@ -3,9 +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 etaForStopId = await contextValue.repository.getEtaForShuttleAndStopId(parent.id, args.forStopId);
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const etaForStopId = await system.etaRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId);
if (etaForStopId === null) return null;
return {
@@ -13,32 +17,46 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
secondsRemaining: etaForStopId.secondsRemaining,
shuttleId: parent.id,
shuttle: parent,
systemId: system.id,
updatedTimeMs: etaForStopId.updatedTime,
};
},
etas: async (parent, args, contextValue, info) => {
const etasForShuttle = await contextValue.repository.getEtasForShuttleId(parent.id);
etas: async (parent, args, contextValue, _) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const etasForShuttle = await system.etaRepository.getEtasForShuttleId(parent.id);
if (!etasForShuttle) return null;
const computedEtas = await Promise.all(etasForShuttle.map(async ({
secondsRemaining,
stopId,
}): Promise<Eta | null> => {
return {
const computedEtas = await Promise.all(
etasForShuttle.map(async ({
secondsRemaining,
stopId,
shuttle: parent,
shuttleId: parent.id,
}
updatedTime
}): Promise<Eta | null> => {
return {
secondsRemaining,
stopId,
shuttle: parent,
shuttleId: parent.id,
systemId: system.id,
updatedTime: updatedTime,
}
}));
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) => {
const route = await contextValue.repository.getRouteById(parent.routeId);
route: async (parent, args, contextValue, _) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const route = await system.shuttleRepository.getRouteById(parent.routeId);
if (route === null) return null;
return {
@@ -46,7 +64,9 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
id: route.id,
name: route.name,
polylineCoordinates: route.polylineCoordinates,
systemId: system.id,
updatedTimeMs: route.updatedTime
}
}
},
}
}

View File

@@ -3,11 +3,21 @@ import { ServerContext } from "../ServerContext";
export const StopResolvers: Resolvers<ServerContext> = {
Stop: {
orderedStops: async (parent, args, contextValue, info) => {
return await contextValue.repository.getOrderedStopsByStopId(parent.id);
orderedStops: async (parent, args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) {
return [];
}
const orderedStops = await system.shuttleRepository.getOrderedStopsByStopId(parent.id);
return orderedStops.slice().sort((a, b) => a.position - b.position);
},
etas: async (parent, args, contextValue, info) => {
return await contextValue.repository.getEtasForStopId(parent.id);
etas: async (parent, args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) {
return [];
}
const etas = await system.etaRepository.getEtasForStopId(parent.id);
return etas.slice().sort((a, b) => a.secondsRemaining - b.secondsRemaining);
},
},
}
}

View File

@@ -3,15 +3,32 @@ import { ServerContext } from "../ServerContext";
export const SystemResolvers: Resolvers<ServerContext> = {
System: {
routes: async (parent, args, contextValue, info) => {
return await contextValue.repository.getRoutesBySystemId(parent.id);
routes: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);
if (!system) {
return [];
}
const routes = await system.shuttleRepository.getRoutes();
return routes.slice().sort((a, b) => a.name.localeCompare(b.name));
},
stops: async (parent, args, contextValue, info) => {
return await contextValue.repository.getStopsBySystemId(parent.id);
stops: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);
if (!system) {
return [];
}
const stops = await system.shuttleRepository.getStops();
return stops.slice().sort((a, b) => a.name.localeCompare(b.name));
},
stop: async (parent, args, contextValue, info) => {
stop: async (parent, args, contextValue, _info) => {
if (!args.id) return null;
const stop = await contextValue.repository.getStopById(args.id);
const system = contextValue.findSystemById(parent.id);
if (!system) {
return null;
}
const stop = await system.shuttleRepository.getStopById(args.id);
if (stop === null) return null;
if (stop.systemId !== parent.id) return null;
@@ -20,11 +37,17 @@ export const SystemResolvers: Resolvers<ServerContext> = {
id: stop.id,
name: stop.name,
coordinates: stop.coordinates as Coordinates,
systemId: parent.id,
updatedTimeMs: stop.updatedTime,
};
},
route: async (parent, args, contextValue, info) => {
route: async (parent, args, contextValue, _info) => {
if (!args.id) return null;
const route = await contextValue.repository.getRouteById(args.id);
const system = contextValue.findSystemById(parent.id);
if (!system) {
return null;
}
const route = await system.shuttleRepository.getRouteById(args.id);
if (route === null) return null;
if (route.systemId !== parent.id) return null;
@@ -34,19 +57,51 @@ export const SystemResolvers: Resolvers<ServerContext> = {
id: route.id,
name: route.name,
polylineCoordinates: route.polylineCoordinates as Coordinates[],
systemId: parent.id,
updatedTimeMs: route.updatedTime,
};
},
shuttle: async (parent, args, contextValue, info) => {
shuttle: async (parent, args, contextValue, _info) => {
if (!args.id) return null;
const shuttle = await contextValue.repository.getShuttleById(args.id);
const system = contextValue.findSystemById(parent.id);
if (!system) {
return null;
}
const shuttle = await system.shuttleRepository.getShuttleById(args.id);
if (shuttle === null) return null;
if (shuttle.systemId !== parent.id) return null;
return shuttle;
},
shuttles: async (parent, args, contextValue, info) => {
return await contextValue.repository.getShuttlesBySystemId(parent.id);
}
shuttles: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);
if (!system) {
return [];
}
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);
if (!system) {
return null;
}
if (!system.parkingRepository) return null;
return {
systemId: parent.id,
};
},
},
}
}

View File

@@ -1,41 +1,39 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { IEta, IShuttle, IStop, ISystem } from "../../src/entities/entities";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import { IEta, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
import {
addMockEtaToRepository, addMockShuttleToRepository,
addMockEtaToRepository,
addMockShuttleToRepository,
addMockStopToRepository,
addMockSystemToRepository
} from "../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
} from "../../../testHelpers/repositorySetupHelpers";
import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository";
import assert from "node:assert";
describe("EtaResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
let mockShuttle: IShuttle;
let mockStop: IStop;
let expectedEta: IEta;
beforeEach(async () => {
mockSystem = await addMockSystemToRepository(context.repository);
mockShuttle = await addMockShuttleToRepository(context.repository, mockSystem.id);
mockStop = await addMockStopToRepository(context.repository, mockSystem.id);
expectedEta = await addMockEtaToRepository(context.repository, mockStop.id, mockShuttle.id);
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].etaRepository as ExternalSourceETARepository, mockStop.id, mockShuttle.id);
});
async function getResponseForEtaQuery(query: string) {
const response = await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
systemId: context.systems[0].id,
shuttleId: mockShuttle.id,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
return response;
}
describe("stop", () => {
@@ -87,4 +85,4 @@ describe("EtaResolvers", () => {
expect(eta.shuttle.id).toEqual(expectedEta.shuttleId);
});
});
});
});

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from "@jest/globals";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import {
addMockShuttleToRepository,
addMockStopToRepository,
} from "../../../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
import { NotificationInput } from "../../generated/graphql";
describe("MutationResolvers", () => {
const holder = setupTestServerHolder()
const context = setupTestServerContext();
async function getServerResponse(query: string, notificationInput: { deviceId: string; shuttleId: string; stopId: string }) {
return await holder.testServer.executeOperation({
query,
variables: {
input: notificationInput,
}
}, {
contextValue: context
});
}
describe("scheduleNotification", () => {
const query = `
mutation ScheduleNotification($input: NotificationInput!) {
scheduleNotification(input: $input) {
success
message
data {
deviceId
shuttleId
stopId
}
}
}
`
async function assertFailedResponse(response: any, notificationInput: NotificationInput) {
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.scheduleNotification as any;
expect(notificationResponse.success).toBe(false);
expect(await context.systems[0].notificationRepository.isNotificationScheduled(notificationInput)).toBe(false);
}
it("adds a notification to the notification service", async () => {
const system = context.systems[0];
const shuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, system.id);
const stop = await addMockStopToRepository(context.systems[0].shuttleRepository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: shuttle.id,
stopId: stop.id,
secondsThreshold: 240,
};
const response = await getServerResponse(query, notificationInput);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const expectedNotificationData: any = {
...notificationInput,
}
delete expectedNotificationData.secondsThreshold;
const notificationResponse = response.body.singleResult.data?.scheduleNotification as any;
expect(notificationResponse?.success).toBe(true);
expect(notificationResponse?.data).toEqual(expectedNotificationData);
expect(await context.systems[0].notificationRepository.getSecondsThresholdForNotificationIfExists(expectedNotificationData)).toBe(240);
});
it("adds a notification with the default seconds threshold if none is provided", async () => {
const system = context.systems[0];
const shuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, system.id);
const stop = await addMockStopToRepository(context.systems[0].shuttleRepository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: shuttle.id,
stopId: stop.id,
};
const response = await getServerResponse(query, notificationInput);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.scheduleNotification as any;
expect(notificationResponse?.success).toBe(true);
expect(await context.systems[0].notificationRepository.getSecondsThresholdForNotificationIfExists(notificationInput)).toBe(180);
});
it("fails if the shuttle ID doesn't exist", async () => {
const system = context.systems[0];
const stop = await addMockStopToRepository(context.systems[0].shuttleRepository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: "1",
stopId: stop.id,
}
const response = await getServerResponse(query, notificationInput);
await assertFailedResponse(response, notificationInput);
});
it("fails if the stop ID doesn't exist", async () => {
const system = context.systems[0];
const shuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: shuttle.id,
stopId: "1",
}
const response = await getServerResponse(query, notificationInput);
await assertFailedResponse(response, notificationInput);
});
});
describe("cancelNotification", () => {
const query = `
mutation CancelNotification($input: NotificationInput!) {
cancelNotification(input: $input) {
success
message
data {
deviceId
shuttleId
stopId
}
}
}
`
it("removes the notification from the notification service", async () => {
const system = context.systems[0];
const shuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, system.id);
const stop = await addMockStopToRepository(context.systems[0].shuttleRepository, system.id);
const notificationInput: any = {
deviceId: "1",
shuttleId: shuttle.id,
stopId: stop.id,
secondsThreshold: 180,
}
await context.systems[0].notificationRepository.addOrUpdateNotification(notificationInput);
const notificationLookup = {
...notificationInput
}
delete notificationLookup.secondsThreshold;
const response = await getServerResponse(query, notificationLookup);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.cancelNotification as any;
expect(notificationResponse.success).toBe(true);
expect(notificationResponse.data).toEqual(notificationLookup);
expect(await context.systems[0].notificationRepository.isNotificationScheduled(notificationLookup)).toBe(false);
});
it("fails if the notification doesn't exist", async () => {
const notificationInput = {
deviceId: "1",
shuttleId: "1",
stopId: "1",
}
const response = await getServerResponse(query, notificationInput);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.cancelNotification as any;
expect(notificationResponse.success).toBe(false);
});
});
});

View File

@@ -1,25 +1,24 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { IRoute, IStop, ISystem } from "../../src/entities/entities";
import { generateMockOrderedStops, generateMockStops } from "../testHelpers/mockDataGenerators";
import { addMockRouteToRepository, addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import { IRoute, IStop } from "../../entities/ShuttleRepositoryEntities";
import { generateMockOrderedStops, generateMockStops } from "../../../testHelpers/mockDataGenerators";
import { addMockRouteToRepository } from "../../../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
describe("OrderedStopResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
let mockRoute: IRoute;
let mockStops: IStop[];
beforeEach(async () => {
mockSystem = await addMockSystemToRepository(context.repository);
mockRoute = await addMockRouteToRepository(context.repository, mockSystem.id);
mockRoute = await addMockRouteToRepository(context.systems[0].shuttleRepository, context.systems[0].id);
mockStops = generateMockStops();
await Promise.all(mockStops.map(async (mockStop) => {
mockStop.systemId = mockSystem.id;
await context.repository.addOrUpdateStop(mockStop);
mockStop.systemId = context.systems[0].id;
await context.systems[0].shuttleRepository.addOrUpdateStop(mockStop);
}));
});
@@ -37,8 +36,8 @@ describe("OrderedStopResolvers", () => {
// Link the stops together
orderedStops[0].nextStop = orderedStops[1];
orderedStops[1].previousStop = orderedStops[0];
await context.repository.addOrUpdateOrderedStop(orderedStops[0]);
await context.repository.addOrUpdateOrderedStop(orderedStops[1]);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(orderedStops[0]);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(orderedStops[1]);
return orderedStops;
}
@@ -59,17 +58,16 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
systemId: context.systems[0].id,
routeId: mockRoute.id,
stopId,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
@@ -93,7 +91,7 @@ describe("OrderedStopResolvers", () => {
it("returns null if there is no next stop in the repository", async () => {
const orderedStops = await setUpOrderedStopsInRepository();
orderedStops[0].nextStop = undefined;
await context.repository.addOrUpdateOrderedStop(orderedStops[0]);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(orderedStops[0]);
const response = await getResponseForNextStopQuery(orderedStops[0].stopId);
@@ -104,7 +102,7 @@ describe("OrderedStopResolvers", () => {
it("returns null if the next stop object no longer exists", async () => {
const orderedStops = await setUpOrderedStopsInRepository();
await context.repository.removeStopIfExists(orderedStops[1].stopId);
await context.systems[0].shuttleRepository.removeStopIfExists(orderedStops[1].stopId);
const response = await getResponseForNextStopQuery(orderedStops[0].stopId);
@@ -131,17 +129,16 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
systemId: context.systems[0].id,
routeId: mockRoute.id,
stopId,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
@@ -164,7 +161,7 @@ describe("OrderedStopResolvers", () => {
it("returns null if there is no previous stop in the repository", async () => {
const orderedStops = await setUpOrderedStopsInRepository();
orderedStops[1].previousStop = undefined;
await context.repository.addOrUpdateOrderedStop(orderedStops[1]);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(orderedStops[1]);
const response = await getResponseForPreviousStopQuery(orderedStops[1].stopId);
@@ -175,7 +172,7 @@ describe("OrderedStopResolvers", () => {
it("returns null if the current stop no longer exists", async () => {
const orderedStops = await setUpOrderedStopsInRepository();
await context.repository.removeStopIfExists(orderedStops[0].stopId);
await context.systems[0].shuttleRepository.removeStopIfExists(orderedStops[0].stopId);
const response = await getResponseForPreviousStopQuery(orderedStops[1].stopId);
@@ -206,16 +203,15 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
systemId: context.systems[0].id,
stopId,
}
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
@@ -225,7 +221,7 @@ describe("OrderedStopResolvers", () => {
orderedStops[0].stopId = mockStops[0].id;
// Add one stop only
await context.repository.addOrUpdateOrderedStop(orderedStops[0]);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(orderedStops[0]);
const response = await getResponseForRouteQuery(orderedStops[1].stopId);
@@ -256,24 +252,23 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
systemId: context.systems[0].id,
routeId: mockRoute.id,
stopId,
}
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
it("returns the associated stop if it exists", async () => {
const orderedStops = await setUpOrderedStopsInRepository();
orderedStops[0].stopId = mockStops[0].id;
await context.repository.addOrUpdateOrderedStop(orderedStops[0]);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(orderedStops[0]);
const response = await getResponseForStopQuery(orderedStops[0].stopId);

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import { InterchangeSystem } from "../../entities/InterchangeSystem";
import { generateParkingStructures } from "../../../testHelpers/mockDataGenerators";
import { HistoricalParkingAverageQueryInput } from "../../generated/graphql";
import assert = require("node:assert");
jest.mock("../../environment");
describe("ParkingStructureResolver", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: InterchangeSystem;
beforeEach(async () => {
mockSystem = context.systems[0];
jest.useRealTimers();
});
describe("historicalAverages", () => {
const query = `
query GetParkingStructureHistoricalAverages(
$systemId: ID!,
$parkingStructureId: ID!,
$historicalAverageInput: HistoricalParkingAverageQueryInput!
) {
system(id: $systemId) {
parkingSystem {
parkingStructure(id: $parkingStructureId) {
historicalAverages(input: $historicalAverageInput) {
from
to
averageSpotsAvailable
}
}
}
}
}
`;
it("gets data for historical averages", async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date());
const parkingStructure = generateParkingStructures()[0];
parkingStructure.spotsAvailable = parkingStructure.capacity;
mockSystem.parkingRepository?.setLoggingInterval(100);
// Simulate repeated updates
for (let i = 0; i < 6; i += 1) {
jest.setSystemTime(new Date(Date.now() + 1000));
parkingStructure.spotsAvailable = parkingStructure.spotsAvailable - 100;
await mockSystem.parkingRepository?.addOrUpdateParkingStructure(parkingStructure);
}
const historicalAverageInput: HistoricalParkingAverageQueryInput = {
from: new Date(Date.now() - 5000).getTime(),
intervalMs: 2000,
to: new Date().getTime(),
};
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
parkingStructureId: parkingStructure.id,
historicalAverageInput,
},
}, {
contextValue: context,
});
assert(response.body.kind === 'single');
expect(response.body.singleResult.errors).toBeUndefined();
const historicalAverages = (response.body.singleResult.data as any).system.parkingSystem.parkingStructure.historicalAverages;
expect(historicalAverages).toHaveLength(3);
});
});
});

View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { generateParkingStructures } from "../../../testHelpers/mockDataGenerators";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import { InterchangeSystem } from "../../entities/InterchangeSystem";
import assert = require("node:assert");
describe("ParkingSystemResolver", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: InterchangeSystem;
beforeEach(async () => {
mockSystem = context.systems[0];
});
async function getResponseFromQueryNeedingSystemId(query: string) {
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
},
}, {
contextValue: context,
});
}
describe("parkingStructures", () => {
const query = `
query GetParkingStructuresBySystem($systemId: ID!) {
system(id: $systemId) {
parkingSystem {
parkingStructures {
name
id
capacity
spotsAvailable
coordinates {
latitude
longitude
}
address
updatedTime
}
}
}
}
`
it("gets parking structures associated with the system id", async () => {
let expectedParkingStructures = generateParkingStructures();
await Promise.all(expectedParkingStructures.map(async (structure) => {
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(structure);
}));
// Dates are transformed into epoch timestamps when serialized
expectedParkingStructures = expectedParkingStructures.map((structure) => {
const newStructure = { ...structure };
// @ts-ignore
newStructure.updatedTime = newStructure.updatedTime.getTime();
return newStructure;
});
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructures = (response.body.singleResult.data as any).system.parkingSystem.parkingStructures;
expect(parkingStructures).toEqual(expectedParkingStructures);
});
it("returns a blank array if there are no parking structures", async () => {
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructures = (response.body.singleResult.data as any).system.parkingSystem.parkingStructures;
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]);
});
});
describe("parkingStructure", () => {
async function getResponseForParkingStructureQuery(parkingStructureId: string) {
const query = `
query GetParkingStructureBySystem($systemId: ID!, $parkingStructureId: ID!) {
system(id: $systemId) {
parkingSystem {
parkingStructure(id: $parkingStructureId) {
name
id
capacity
spotsAvailable
coordinates {
latitude
longitude
}
address
updatedTime
}
}
}
}
`;
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
parkingStructureId,
}
}, {
contextValue: context
});
}
it("returns the correct parking structure given the id", async () => {
const generatedParkingStructures = generateParkingStructures();
await Promise.all(generatedParkingStructures.map(async (structure) => {
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(structure);
}));
const expectedParkingStructure = generatedParkingStructures[1];
// @ts-ignore
expectedParkingStructure.updatedTime = expectedParkingStructure.updatedTime.getTime();
const response = await getResponseForParkingStructureQuery(expectedParkingStructure.id);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructure = (response.body.singleResult.data as any).system.parkingSystem.parkingStructure;
expect(parkingStructure).toEqual(expectedParkingStructure);
});
it("returns null if there is no matching parking structure", async () => {
const generatedParkingStructures = generateParkingStructures();
await Promise.all(generatedParkingStructures.map(async (structure) => {
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(structure);
}));
const nonexistentId = generatedParkingStructures[0].id + "12345";
const response = await getResponseForParkingStructureQuery(nonexistentId);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const parkingStructure = (response.body.singleResult.data as any).system.parkingSystem.parkingStructure;
expect(parkingStructure).toBeNull();
});
});
});

Some files were not shown because too many files have changed in this diff Show More