664 Commits

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

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

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

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

93
CLAUDE.md Normal file
View File

@@ -0,0 +1,93 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
## Development Commands
### Core Development
```bash
# Start development server with hot reloading
docker compose run dev
# Run comprehensive test suite
docker compose run test
# Generate GraphQL TypeScript types
npm run generate
# Build for development (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.

4
Caddyfile Normal file
View File

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

4
Dockerfile Normal file
View File

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

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.

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

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

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,

4182
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",
"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",
"@as-integrations/express5": "^1.1.2",
"express": "^5.1.0",
"express-rate-limit": "^8.0.1",
"express-slow-down": "^3.0.0",
"graphql": "^16.10.0",
"jsonwebtoken": "^9.0.2"
"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,8 +1,8 @@
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!
@@ -12,15 +12,51 @@ type System {
stop(id: ID): Stop
shuttles: [Shuttle!]
shuttle(id: ID): Shuttle
parkingSystem: ParkingSystem
}
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 +66,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 +91,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,285 @@
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";
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;
}
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,
) {
}
/**
* 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,
);
}
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,
);
}
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,13 @@
export interface IEntityWithTimestamp {
updatedTime: Date;
}
export interface IEntityWithId {
id: string;
}
export interface ICoordinates {
latitude: number;
longitude: number;
}

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