492 Commits

Author SHA1 Message Date
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
111 changed files with 11659 additions and 3710 deletions

4
.dockerignore Normal file
View File

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

View File

@@ -1,4 +1,23 @@
# 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=

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
-

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>

101
CLAUDE.md Normal file
View File

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

4
Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM node:20-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.

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
# 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
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,

4179
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,23 +9,30 @@
"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"
}
}

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,187 @@
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";
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;
}
export class InterchangeSystem {
private constructor(
public name: string,
public id: string,
public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader,
public shuttleRepository: ShuttleGetterSetterRepository,
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 = new UnoptimizedInMemoryShuttleRepository();
const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository
);
const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader(
shuttleDataLoader,
);
await timedShuttleDataLoader.start();
const notificationRepository = new RedisNotificationRepository();
await notificationRepository.connect();
const notificationScheduler = new ETANotificationScheduler(
shuttleRepository,
notificationRepository,
new AppleNotificationSender(),
args.id,
);
notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = await this.buildRedisParkingLoaderAndRepository(args.parkingSystemId);
timedParkingLoader?.start();
return new InterchangeSystem(
args.name,
args.id,
timedShuttleDataLoader,
shuttleRepository,
notificationScheduler,
notificationRepository,
timedParkingLoader,
parkingRepository,
);
}
/**
* Construct an instance of the class where all composited
* classes are correctly linked, meant for unit tests, and server/app
* integration tests.
* @param args
*/
static buildForTesting(
args: InterchangeSystemBuilderArguments,
) {
const shuttleRepository = new UnoptimizedInMemoryShuttleRepository();
const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader(
args.passioSystemId,
args.id,
shuttleRepository
);
// Note that this loader should not be started,
// so the test data doesn't get overwritten
const timedShuttleLoader = new TimedApiBasedRepositoryLoader(
shuttleDataLoader,
);
const notificationRepository = new InMemoryNotificationRepository();
const notificationScheduler = new ETANotificationScheduler(
shuttleRepository,
notificationRepository,
new AppleNotificationSender(false),
args.id,
);
notificationScheduler.startListeningForUpdates();
let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId);
// Timed parking loader is not started here
return new InterchangeSystem(
args.name,
args.id,
timedShuttleLoader,
shuttleRepository,
notificationScheduler,
notificationRepository,
timedParkingLoader,
parkingRepository,
);
}
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 };
}
private static buildInMemoryParkingLoaderAndRepository(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 };
}
}

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

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

24
src/environment.ts Normal file
View File

@@ -0,0 +1,24 @@
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;

View File

@@ -1,41 +1,89 @@
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",
}
]
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

@@ -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 {
fetchAndUpdateAll(): 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.fetchAndUpdateAll();
} 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, beforeAll, 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 = {
fetchAndUpdateAll: jest.spyOn(mockLoader, 'fetchAndUpdateAll'),
};
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 fetchAndUpdateAll() {
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.fetchAndUpdateAll();
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

@@ -1,24 +1,21 @@
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";
}
}
import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository";
import { IEta, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader";
import { IEntityWithId } from "../../entities/SharedEntities";
import { ApiResponseError } from "../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"];
export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader {
baseUrl = "https://passiogo.com/mapGetData.php";
constructor(
public repository: GetterSetterRepository,
public passioSystemId: string,
public systemIdForConstructedData: string,
public repository: ShuttleGetterSetterRepository,
) {
}
@@ -31,59 +28,21 @@ export class ApiBasedRepositoryLoader {
return ids;
}
public async fetchAndUpdateSystemData() {
const params = {
getSystems: "2",
};
const query = new URLSearchParams(params).toString();
public async fetchAndUpdateAll() {
await this.fetchAndUpdateRouteDataForSystem();
await this.fetchAndUpdateStopAndPolylineDataForRoutesInSystem();
await this.fetchAndUpdateShuttleDataForSystem();
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);
}
// Because ETA method doesn't support pruning yet,
// add a call to the clear method here
await this.repository.clearEtaData();
await this.fetchAndUpdateEtaDataForExistingStopsForSystem();
}
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) {
public async fetchAndUpdateRouteDataForSystem() {
const systemId = this.passioSystemId;
const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getRoutesBySystemId(systemId);
return await this.repository.getRoutes();
});
const params = {
@@ -113,7 +72,8 @@ export class ApiBasedRepositoryLoader {
color: jsonRoute.color,
id: jsonRoute.myid,
polylineCoordinates: [],
systemId: systemId,
systemId: this.systemIdForConstructedData,
updatedTime: new Date(),
};
await this.repository.addOrUpdateRoute(constructedRoute);
@@ -130,18 +90,13 @@ export class ApiBasedRepositoryLoader {
}
}
public async fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository() {
const systems = await this.repository.getSystems();
await Promise.all(systems.map(async (system: ISystem) => {
await this.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(system.id);
}));
}
public async fetchAndUpdateStopAndPolylineDataForRoutesInSystem() {
const passioSystemId = this.passioSystemId;
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);
return await this.repository.getStops();
});
const params = {
@@ -149,7 +104,7 @@ export class ApiBasedRepositoryLoader {
};
const formDataJsonObject = {
"s0": systemId,
"s0": passioSystemId,
"sA": 1
};
const formData = new FormData();
@@ -164,7 +119,7 @@ export class ApiBasedRepositoryLoader {
});
const json = await response.json();
await this.updateStopDataForSystemAndApiResponse(systemId, json, stopIdsToPrune);
await this.updateStopDataForSystemAndApiResponse(json, stopIdsToPrune);
await this.updateOrderedStopDataForExistingStops(json);
await this.updatePolylineDataForExistingRoutesAndApiResponse(json);
@@ -176,17 +131,10 @@ export class ApiBasedRepositoryLoader {
}
}
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) {
public async fetchAndUpdateShuttleDataForSystem() {
const systemId = this.passioSystemId;
const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => {
return await this.repository.getShuttlesBySystemId(systemId);
return await this.repository.getShuttles();
});
const params = {
@@ -223,8 +171,10 @@ export class ApiBasedRepositoryLoader {
longitude: parseFloat(jsonBus.longitude),
},
routeId: jsonBus.routeId,
systemId: systemId,
id: `${jsonBus.busId}`
systemId: this.systemIdForConstructedData,
id: `${jsonBus.busId}`,
orientationInDegrees: parseFloat(jsonBus.calculatedCourse),
updatedTime: new Date(),
}
await this.repository.addOrUpdateShuttle(constructedShuttle);
@@ -241,16 +191,8 @@ export class ApiBasedRepositoryLoader {
}
}
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);
public async fetchAndUpdateEtaDataForExistingStopsForSystem() {
const stops = await this.repository.getStops();
await Promise.all(stops.map(async (stop) => {
let stopId = stop.id;
await this.fetchAndUpdateEtaDataForStopId(stopId);
@@ -281,7 +223,8 @@ export class ApiBasedRepositoryLoader {
secondsRemaining: jsonEta.secondsSpent,
shuttleId: `${shuttleId}`,
stopId: stopId,
millisecondsSinceEpoch: Date.now(),
updatedTime: new Date(),
systemId: this.systemIdForConstructedData,
};
this.repository.addOrUpdateEta(eta);
@@ -293,7 +236,6 @@ export class ApiBasedRepositoryLoader {
}
protected async updateStopDataForSystemAndApiResponse(
systemId: string,
json: any,
setOfIdsToPrune: Set<string> = new Set(),
) {
@@ -304,11 +246,12 @@ export class ApiBasedRepositoryLoader {
const constructedStop: IStop = {
name: stop.name,
id: stop.id,
systemId,
systemId: this.systemIdForConstructedData,
coordinates: {
latitude: parseFloat(stop.latitude),
longitude: parseFloat(stop.longitude),
},
updatedTime: new Date(),
};
await this.repository.addOrUpdateStop(constructedStop);
@@ -339,6 +282,8 @@ export class ApiBasedRepositoryLoader {
routeId,
stopId,
position: index + 1,
systemId: this.systemIdForConstructedData,
updatedTime: new Date(),
};
}
@@ -347,6 +292,8 @@ export class ApiBasedRepositoryLoader {
routeId,
stopId: jsonOrderedStopData[index - 1][1],
position: index,
systemId: this.systemIdForConstructedData,
updatedTime: new Date(),
};
}
if (index < jsonOrderedStopData.length - 1) {
@@ -354,6 +301,8 @@ export class ApiBasedRepositoryLoader {
routeId,
stopId: jsonOrderedStopData[index + 1][1],
position: index + 2,
systemId: this.systemIdForConstructedData,
updatedTime: new Date(),
};
}
@@ -382,4 +331,4 @@ export class ApiBasedRepositoryLoader {
}))
}
}
}
}

View File

@@ -0,0 +1,9 @@
import { RepositoryLoader } from "../RepositoryLoader";
export interface ShuttleRepositoryLoader extends RepositoryLoader {
fetchAndUpdateRouteDataForSystem(): Promise<void>;
fetchAndUpdateStopAndPolylineDataForRoutesInSystem(): Promise<void>;
fetchAndUpdateShuttleDataForSystem(): Promise<void>;
fetchAndUpdateEtaDataForExistingStopsForSystem(): Promise<void>;
fetchAndUpdateEtaDataForStopId(stopId: string): Promise<void>;
}

View File

@@ -0,0 +1,195 @@
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 { fetchEtaDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchEtaData/fetchEtaDataSuccessfulResponse";
import {
resetGlobalFetchMockJson,
updateGlobalFetchMockJson,
updateGlobalFetchMockJsonToThrowSyntaxError
} from "../../../../testHelpers/fetchMockHelpers";
import { assertAsyncCallbackThrowsApiResponseError } from "../../../../testHelpers/assertAsyncCallbackThrowsApiResponseError";
describe("ApiBasedShuttleRepositoryLoader", () => {
let loader: ApiBasedShuttleRepositoryLoader;
beforeEach(() => {
loader = new ApiBasedShuttleRepositoryLoader("263", "1", new UnoptimizedInMemoryShuttleRepository());
resetGlobalFetchMockJson();
});
afterEach(() => {
jest.clearAllMocks();
});
const systemId = "1";
describe("fetchAndUpdateAll", () => {
it("calls all the correct methods", async () => {
const spies = {
fetchAndUpdateRouteDataForSystem: jest.spyOn(loader, "fetchAndUpdateRouteDataForSystem"),
fetchAndUpdateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, "fetchAndUpdateStopAndPolylineDataForRoutesInSystem"),
fetchAndUpdateShuttleDataForSystem: jest.spyOn(loader, "fetchAndUpdateShuttleDataForSystem"),
fetchAndUpdateEtaDataForExistingStopsForSystem: jest.spyOn(loader, "fetchAndUpdateEtaDataForExistingStopsForSystem"),
};
Object.values(spies).forEach((spy: any) => {
spy.mockResolvedValue(undefined);
});
await loader.fetchAndUpdateAll();
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalled();
});
});
});
describe("fetchAndUpdateRouteDataForSystem", () => {
it("updates route data in repository if response received", async () => {
// Arrange
// Test pruning
const routesToPrune = generateMockRoutes();
await Promise.all(routesToPrune.map(async (route) => {
route.systemId = systemId;
await loader.repository.addOrUpdateRoute(route);
}));
updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse);
// Act
await loader.fetchAndUpdateRouteDataForSystem();
// Assert
const routes = await loader.repository.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.fetchAndUpdateRouteDataForSystem();
});
});
});
describe("fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId", () => {
it("updates stop and polyline data if response received", async () => {
// Arrange
// Test pruning of stops only
const stopsToPrune = generateMockStops();
await Promise.all(stopsToPrune.map(async (stop) => {
stop.systemId = systemId;
await loader.repository.addOrUpdateStop(stop);
}));
updateGlobalFetchMockJson(fetchStopAndPolylineDataSuccessfulResponse);
const stopsArray = Object.values(fetchStopAndPolylineDataSuccessfulResponse.stops);
await loader.fetchAndUpdateStopAndPolylineDataForRoutesInSystem();
const stops = await loader.repository.getStops();
expect(stops.length).toEqual(stopsArray.length);
await Promise.all(stops.map(async (stop) => {
const orderedStops = await loader.repository.getOrderedStopsByStopId(stop.id)
expect(orderedStops.length).toBeGreaterThan(0);
}));
const routes = await loader.repository.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.fetchAndUpdateStopAndPolylineDataForRoutesInSystem();
});
})
});
describe("fetchAndUpdateShuttleDataForSystem", () => {
it("updates shuttle data in repository if response received", async () => {
const shuttlesToPrune = generateMockShuttles();
await Promise.all(shuttlesToPrune.map(async (shuttle) => {
shuttle.systemId = systemId;
await loader.repository.addOrUpdateShuttle(shuttle);
}))
updateGlobalFetchMockJson(fetchShuttleDataSuccessfulResponse);
const busesInResponse = Object.values(fetchShuttleDataSuccessfulResponse.buses);
await loader.fetchAndUpdateShuttleDataForSystem();
const shuttles = await loader.repository.getShuttles();
expect(shuttles.length).toEqual(busesInResponse.length);
});
it("throws the correct error if the API response contains no data", async () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateShuttleDataForSystem();
});
});
});
describe("fetchAndUpdateEtaDataForExistingStopsForSystem", () => {
it("calls fetchAndUpdateEtaDataForStopId for every stop in repository", async () => {
const spy = jest.spyOn(loader, "fetchAndUpdateEtaDataForStopId");
const stops = generateMockStops();
stops.forEach((stop) => {
stop.systemId = "1";
});
await Promise.all(stops.map(async (stop) => {
await loader.repository.addOrUpdateStop(stop);
}));
await loader.fetchAndUpdateEtaDataForExistingStopsForSystem();
expect(spy.mock.calls.length).toEqual(stops.length);
});
});
describe("fetchAndUpdateEtaDataForStopId", () => {
const stopId = "177666";
it("updates ETA data for stop id if response received", async () => {
updateGlobalFetchMockJson(fetchEtaDataSuccessfulResponse);
// @ts-ignore
const etasFromResponse = fetchEtaDataSuccessfulResponse.ETAs[stopId]
await loader.fetchAndUpdateEtaDataForStopId(stopId);
const etas = await loader.repository.getEtasForStopId(stopId);
expect(etas.length).toEqual(etasFromResponse.length);
});
it("throws the correct error if the API response contains no data", async () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateEtaDataForStopId("263");
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import { InterchangeSystemBuilderArguments } from "../entities/InterchangeSystem";
import { ChapmanApiBasedParkingRepositoryLoader } from "./parking/ChapmanApiBasedParkingRepositoryLoader";
export const supportedIntegrationTestSystems: InterchangeSystemBuilderArguments[] = [
{
id: "1",
name: "Chapman University",
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
},
];

View File

@@ -0,0 +1,99 @@
import { ShuttleGetterRepository } from "../../repositories/shuttle/ShuttleGetterRepository";
import { IEta } from "../../entities/ShuttleRepositoryEntities";
import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender";
import {
NotificationRepository,
ScheduledNotification
} from "../../repositories/notifications/NotificationRepository";
import { InMemoryNotificationRepository } from "../../repositories/notifications/InMemoryNotificationRepository";
export class ETANotificationScheduler {
public static readonly defaultSecondsThresholdForNotificationToFire = 180;
constructor(
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.shuttleRepository.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.shuttleRepository.subscribeToEtaUpdates(this.etaSubscriberCallback);
}
public stopListeningForUpdates() {
this.shuttleRepository.subscribeToEtaUpdates(this.etaSubscriberCallback);
}
}

View File

@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { ETANotificationScheduler } from "../ETANotificationScheduler";
import { UnoptimizedInMemoryShuttleRepository } from "../../../repositories/shuttle/UnoptimizedInMemoryShuttleRepository";
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 notificationService: ETANotificationScheduler;
let notificationRepository: NotificationRepository;
beforeEach(() => {
shuttleRepository = new UnoptimizedInMemoryShuttleRepository();
notificationRepository = new InMemoryNotificationRepository();
mockNotificationSenderMethods(true);
const appleNotificationSender = new MockAppleNotificationSender(false);
notificationService = new ETANotificationScheduler(
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 shuttleRepository.addOrUpdateEta(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 shuttleRepository.addOrUpdateEta(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(
shuttleRepository,
notificationRepository,
updatedNotificationSender,
"1",
);
notificationService.startListeningForUpdates();
// Act
await notificationRepository.addOrUpdateNotification(notificationData1);
await shuttleRepository.addOrUpdateEta(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,38 @@
import { createClient } from 'redis';
import { REDIS_RECONNECT_INTERVAL } from "../environment";
export abstract class BaseRedisRepository {
protected redisClient;
constructor(
redisClient = createClient({
url: process.env.REDIS_URL,
socket: {
tls: process.env.NODE_ENV === 'production',
rejectUnauthorized: false,
reconnectStrategy: REDIS_RECONNECT_INTERVAL,
},
}),
) {
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();
}
public async clearAllData() {
await this.redisClient.flushAll();
}
}

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 listeners: 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.listeners.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.listeners.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.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,213 @@
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
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;
name = 'RedisNotificationRepository';
factory = async () => {
this.repo = new RedisNotificationRepository();
await this.repo.connect();
return this.repo;
}
teardown = async () => {
if (this.repo) {
await this.repo.clearAllData();
await this.repo.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, HistoricalParkingAverageQueryArguments } 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: HistoricalParkingAverageQueryArguments): 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: HistoricalParkingAverageQueryArguments
): 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 HistoricalParkingAverageQueryArguments {
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: HistoricalParkingAverageQueryArguments): 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, HistoricalParkingAverageQueryArguments } 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: HistoricalParkingAverageQueryArguments): 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: HistoricalParkingAverageQueryArguments
): 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 { InMemoryParkingRepository, } from "../InMemoryParkingRepository";
import { IParkingStructure } from "../../../entities/ParkingRepositoryEntities";
import { HistoricalParkingAverageQueryArguments } from "../ParkingGetterRepository";
import { ParkingGetterSetterRepository } from "../ParkingGetterSetterRepository";
import { RedisParkingRepository } from "../RedisParkingRepository";
interface RepositoryHolder {
name: string;
factory(): Promise<ParkingGetterSetterRepository>;
teardown(): Promise<void>;
}
class InMemoryParkingRepositoryHolder implements RepositoryHolder {
name = 'InMemoryParkingRepository';
factory = async () => {
return new InMemoryParkingRepository();
};
teardown = async () => {};
}
class RedisParkingRepositoryHolder implements RepositoryHolder {
repo: RedisParkingRepository | undefined;
name = 'RedisParkingRepository';
factory = async () => {
this.repo = new RedisParkingRepository();
await this.repo.connect();
return this.repo;
};
teardown = async () => {
if (this.repo) {
await this.repo.clearAllData();
await this.repo.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: HistoricalParkingAverageQueryArguments = {
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: HistoricalParkingAverageQueryArguments = {
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

@@ -1,24 +1,41 @@
import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
export interface GetterRepository {
getSystems(): Promise<ISystem[]>;
getSystemById(systemId: string): Promise<ISystem | null>;
getStopsBySystemId(systemId: string): Promise<IStop[]>;
/**
* Shuttle getter repository to be linked to a system.
*/
export interface ShuttleGetterRepository {
getStops(): Promise<IStop[]>;
getStopById(stopId: string): Promise<IStop | null>;
getRoutesBySystemId(systemId: string): Promise<IRoute[]>;
getRoutes(): Promise<IRoute[]>;
getRouteById(routeId: string): Promise<IRoute | null>;
getShuttlesBySystemId(systemId: string): Promise<IShuttle[]>;
getShuttles(): 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>;
/**
* Subscribe to all updates in ETA data.
* The subscriber persists even if the ETA data does not
* exist within the repository, and may fire again
* if ETA data is restored.
* @param listener
*/
subscribeToEtaUpdates(
listener: (eta: IEta) => void,
): void;
/**
* Unsubscribe from all ETA updates for the given callback.
* Callback must be passed by reference.
* @param listener
*/
unsubscribeFromEtaUpdates(listener: (eta: IEta) => void): void;
getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise<IOrderedStop | null>;
/**
@@ -34,4 +51,4 @@ export interface GetterRepository {
* @param routeId
*/
getOrderedStopsByRouteId(routeId: string): Promise<IOrderedStop[]>;
}
}

View File

@@ -1,32 +1,29 @@
// 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";
import { ShuttleGetterRepository } from "./ShuttleGetterRepository";
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
/**
* GetterRepository interface for data derived from Passio API.
* 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 GetterSetterRepository extends GetterRepository {
export interface ShuttleGetterSetterRepository extends ShuttleGetterRepository {
// 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>;

View File

@@ -1,45 +1,39 @@
import { GetterSetterRepository } from "./GetterSetterRepository";
import { IEntityWithId, IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../entities/entities";
import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository";
import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities";
import { IEntityWithId } from "../../entities/SharedEntities";
/**
* 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[] = [];
export class UnoptimizedInMemoryShuttleRepository implements ShuttleGetterSetterRepository {
private stops: IStop[] = [];
private routes: IRoute[] = [];
private shuttles: IShuttle[] = [];
private etas: IEta[] = [];
private orderedStops: IOrderedStop[] = [];
public async getSystems() {
return this.systems;
}
private subscribers: ((eta: IEta) => void)[] = [];
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 getStops(): Promise<IStop[]> {
return this.stops;
}
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 getRoutes(): Promise<IRoute[]> {
return this.routes;
}
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 getShuttles(): Promise<IShuttle[]> {
return this.shuttles;
}
public async getShuttlesByRouteId(routeId: string) {
@@ -49,8 +43,8 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
public async getShuttleById(shuttleId: string) {
return this.findEntityById(shuttleId, this.shuttles);
}
public async getEtasForShuttleId(shuttleId: string) {
public async getEtasForShuttleId(shuttleId: string): Promise<IEta[]> {
return this.etas.filter(eta => eta.shuttleId === shuttleId);
}
@@ -58,6 +52,17 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
return this.etas.filter(eta => eta.stopId === stopId);
}
public subscribeToEtaUpdates(listener: (eta: IEta) => void) {
this.subscribers.push(listener);
}
public unsubscribeFromEtaUpdates(listener: (eta: IEta) => void) {
const index = this.subscribers.findIndex((existingListener) => existingListener == listener);
if (index >= 0) {
this.subscribers.splice(index, 1);
}
}
public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string) {
return this.findEntityByMatcher<IEta>((value) => value.stopId === stopId && value.shuttleId === shuttleId, this.etas);
}
@@ -86,15 +91,6 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
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) {
@@ -138,6 +134,13 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
} else {
this.etas.push(eta);
}
this.publishEtaUpdateToSubscribers(eta);
}
private publishEtaUpdateToSubscribers(eta: IEta) {
this.subscribers.forEach(subscriber => {
subscriber(eta);
});
}
private async removeEntityByMatcherIfExists<T>(callback: (value: T) => boolean, arrayToSearchIn: T[]) {
@@ -155,10 +158,6 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
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);
}
@@ -185,10 +184,6 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
}, this.etas);
}
public async clearSystemData() {
this.systems = [];
}
public async clearShuttleData(): Promise<void> {
this.shuttles = [];
}
@@ -209,4 +204,4 @@ export class UnoptimizedInMemoryRepository implements GetterSetterRepository {
this.stops = [];
}
}
}

View File

@@ -1,77 +1,38 @@
import { beforeEach, describe, expect, test } from "@jest/globals";
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
import { beforeEach, describe, expect, jest, test } from "@jest/globals";
import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShuttleRepository";
import {
generateMockEtas,
generateMockOrderedStops,
generateMockRoutes,
generateMockShuttles,
generateMockStops,
generateMockSystems
} from "../testHelpers/mockDataGenerators";
} from "../../../../testHelpers/mockDataGenerators";
// For repositories created in the future, reuse core testing
// logic from here and differentiate setup (e.g. creating mocks)
// Do this by creating a function which takes a GetterRepository
// or GetterSetterRepository instance
// Do this by creating a function which takes a ShuttleGetterRepository
// or ShuttleGetterSetterRepository instance
describe("UnoptimizedInMemoryRepository", () => {
let repository: UnoptimizedInMemoryRepository;
let repository: UnoptimizedInMemoryShuttleRepository;
beforeEach(() => {
repository = new UnoptimizedInMemoryRepository();
repository = new UnoptimizedInMemoryShuttleRepository();
});
describe("getSystems", () => {
test("gets the systems stored in the repository", async () => {
const mockSystems = generateMockSystems();
for (const system of mockSystems) {
await repository.addOrUpdateSystem(system);
}
const result = await repository.getSystems();
expect(result).toEqual(mockSystems);
});
test("gets an empty list if there are no systems stored", async () => {
const result = await repository.getSystems();
expect(result).toEqual([]);
});
});
describe("getSystemById", () => {
test("gets a system by the ID if it exists", async () => {
const mockSystems = generateMockSystems();
for (const system of mockSystems) {
await repository.addOrUpdateSystem(system);
}
const result = await repository.getSystemById("2");
expect(result).toEqual(mockSystems[1]); // Ensure it retrieves the correct system
});
test("returns null if the system doesn't exist", async () => {
const result = await repository.getSystemById("nonexistent-id");
expect(result).toBeNull();
});
});
describe("getStopsBySystemId", () => {
test("gets stops by system ID", async () => {
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.getStopsBySystemId("sys1");
expect(result).toEqual(mockStops.filter((stop) => stop.systemId === "sys1"));
const result = await repository.getStops();
expect(result).toEqual(mockStops);
});
test("returns an empty list if there are no stops for the given system ID", async () => {
const result = await repository.getStopsBySystemId("nonexistent-system");
const result = await repository.getStops();
expect(result).toEqual([]);
});
});
@@ -92,19 +53,19 @@ describe("UnoptimizedInMemoryRepository", () => {
});
});
describe("getRoutesBySystemId", () => {
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.getRoutesBySystemId("sys1");
expect(result).toEqual(mockRoutes.filter((route) => route.systemId === "sys1"));
const result = await repository.getRoutes();
expect(result).toEqual(mockRoutes);
});
test("returns an empty list if there are no routes for the system ID", async () => {
const result = await repository.getRoutesBySystemId("nonexistent-system");
const result = await repository.getRoutes();
expect(result).toEqual([]);
});
});
@@ -124,19 +85,19 @@ describe("UnoptimizedInMemoryRepository", () => {
expect(result).toBeNull();
});
});
describe("getShuttlesBySystemId", () => {
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.getShuttlesBySystemId("sys1");
expect(result).toEqual(mockShuttles.filter((sh) => sh.systemId === "sys1"));
const result = await repository.getShuttles();
expect(result).toEqual(mockShuttles);
});
test("returns an empty list if there are no shuttles for the system ID", async () => {
const result = await repository.getShuttlesBySystemId("nonexistent-system");
const result = await repository.getShuttles();
expect(result).toEqual([]);
});
});
@@ -225,6 +186,51 @@ describe("UnoptimizedInMemoryRepository", () => {
});
});
describe("subscribeToEtaChanges", () => {
test("notifies listeners if etas have been added or changed", async () => {
const mockCallback = jest.fn(); // Jest mock function to simulate a listener
repository.subscribeToEtaUpdates(mockCallback);
const mockEtas = generateMockEtas();
for (const eta of mockEtas) {
await repository.addOrUpdateEta(eta); // Trigger changes in ETAs
}
expect(mockCallback).toHaveBeenCalledTimes(mockEtas.length);
expect(mockCallback).toHaveBeenCalledWith(mockEtas[0]); // First notification
expect(mockCallback).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification
});
});
describe("unsubscribeFromEtaChanges", () => {
test("stops notifying listeners after etas have stopped changing", async () => {
const mockCallback = jest.fn(); // Jest mock function to simulate a listener
repository.subscribeToEtaUpdates(mockCallback);
const mockEtas = generateMockEtas();
await repository.addOrUpdateEta(mockEtas[0]);
repository.unsubscribeFromEtaUpdates(mockCallback);
await repository.addOrUpdateEta(mockEtas[mockEtas.length - 1]);
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(mockEtas[0]); // First notification
expect(mockCallback).not.toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification
});
test("does nothing if the listener doesn't exist", async () => {
const mockCallback = jest.fn();
repository.subscribeToEtaUpdates(mockCallback);
const mockEtas = generateMockEtas();
repository.unsubscribeFromEtaUpdates(() => {});
await repository.addOrUpdateEta(mockEtas[0]);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
});
describe("getOrderedStopByRouteAndStopId", () => {
test("gets an ordered stop by route ID and stop ID", async () => {
const mockOrderedStops = generateMockOrderedStops();
@@ -279,31 +285,6 @@ describe("UnoptimizedInMemoryRepository", () => {
});
});
describe("addOrUpdateSystem", () => {
test("adds a new system if nonexistent", async () => {
const mockSystems = generateMockSystems();
const newSystem = mockSystems[0];
await repository.addOrUpdateSystem(newSystem);
const result = await repository.getSystems();
expect(result).toEqual([newSystem]);
});
test("updates an existing system if it exists", async () => {
const mockSystems = generateMockSystems();
const existingSystem = mockSystems[0];
const updatedSystem = structuredClone(existingSystem);
updatedSystem.name = "Updated System";
await repository.addOrUpdateSystem(existingSystem);
await repository.addOrUpdateSystem(updatedSystem);
const result = await repository.getSystems();
expect(result).toEqual([updatedSystem]);
});
});
describe("addOrUpdateRoute", () => {
test("adds a new route if nonexistent", async () => {
const mockRoutes = generateMockRoutes();
@@ -311,7 +292,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.addOrUpdateRoute(newRoute);
const result = await repository.getRoutesBySystemId("sys1");
const result = await repository.getRoutes();
expect(result).toEqual([newRoute]);
});
@@ -324,7 +305,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.addOrUpdateRoute(existingRoute);
await repository.addOrUpdateRoute(updatedRoute);
const result = await repository.getRoutesBySystemId("sys1");
const result = await repository.getRoutes();
expect(result).toEqual([updatedRoute]);
});
});
@@ -336,7 +317,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.addOrUpdateShuttle(newShuttle);
const result = await repository.getShuttlesBySystemId("sys1");
const result = await repository.getShuttles();
expect(result).toEqual([newShuttle]);
});
@@ -349,7 +330,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.addOrUpdateShuttle(existingShuttle);
await repository.addOrUpdateShuttle(updatedShuttle);
const result = await repository.getShuttlesBySystemId("sys1");
const result = await repository.getShuttles();
expect(result).toEqual([updatedShuttle]);
});
});
@@ -361,7 +342,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.addOrUpdateStop(newStop);
const result = await repository.getStopsBySystemId("sys1");
const result = await repository.getStops();
expect(result).toEqual([newStop]);
});
@@ -374,7 +355,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.addOrUpdateStop(existingStop);
await repository.addOrUpdateStop(updatedStop);
const result = await repository.getStopsBySystemId("sys1");
const result = await repository.getStops();
expect(result).toEqual([updatedStop]);
});
});
@@ -429,33 +410,6 @@ describe("UnoptimizedInMemoryRepository", () => {
});
});
describe("removeSystemIfExists", () => {
test("removes system given ID", async () => {
const mockSystems = generateMockSystems();
await Promise.all(mockSystems.map(async (system) => {
await repository.addOrUpdateSystem(system);
}));
const systemToRemove = mockSystems[0];
await repository.removeSystemIfExists(systemToRemove.id);
const remainingSystems = await repository.getSystems();
expect(remainingSystems).toHaveLength(mockSystems.length - 1);
});
test("does nothing if system doesn't exist", async () => {
const mockSystems = generateMockSystems();
await Promise.all(mockSystems.map(async (system) => {
await repository.addOrUpdateSystem(system);
}));
await repository.removeSystemIfExists("nonexistent-id");
const remainingSystems = await repository.getSystems();
expect(remainingSystems).toHaveLength(mockSystems.length);
});
});
describe("removeRouteIfExists", () => {
test("removes route given ID", async () => {
const systemId = "1";
@@ -468,7 +422,7 @@ describe("UnoptimizedInMemoryRepository", () => {
const routeToRemove = mockRoutes[0];
await repository.removeRouteIfExists(routeToRemove.id);
const remainingRoutes = await repository.getRoutesBySystemId(systemId);
const remainingRoutes = await repository.getRoutes();
expect(remainingRoutes).toHaveLength(mockRoutes.length - 1);
});
@@ -482,7 +436,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.removeRouteIfExists("nonexistent-id");
const remainingRoutes = await repository.getRoutesBySystemId(systemId);
const remainingRoutes = await repository.getRoutes();
expect(remainingRoutes).toHaveLength(mockRoutes.length);
});
});
@@ -499,7 +453,7 @@ describe("UnoptimizedInMemoryRepository", () => {
const shuttleToRemove = mockShuttles[0];
await repository.removeShuttleIfExists(shuttleToRemove.id);
const remainingShuttles = await repository.getShuttlesBySystemId(systemId);
const remainingShuttles = await repository.getShuttles();
expect(remainingShuttles).toHaveLength(mockShuttles.length - 1);
});
@@ -513,7 +467,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.removeShuttleIfExists("nonexistent-id");
const remainingShuttles = await repository.getShuttlesBySystemId(systemId);
const remainingShuttles = await repository.getShuttles();
expect(remainingShuttles).toHaveLength(mockShuttles.length);
});
});
@@ -530,7 +484,7 @@ describe("UnoptimizedInMemoryRepository", () => {
const stopToRemove = mockStops[0];
await repository.removeStopIfExists(stopToRemove.id);
const remainingStops = await repository.getStopsBySystemId(systemId);
const remainingStops = await repository.getStops();
expect(remainingStops).toHaveLength(mockStops.length - 1);
});
@@ -544,7 +498,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.removeStopIfExists("nonexistent-id");
const remainingStops = await repository.getStopsBySystemId(systemId);
const remainingStops = await repository.getStops();
expect(remainingStops).toHaveLength(mockStops.length);
});
});
@@ -617,31 +571,6 @@ describe("UnoptimizedInMemoryRepository", () => {
});
});
describe("clearSystemData", () => {
test("clears all systems from the repository", async () => {
const mockSystems = generateMockSystems();
for (const system of mockSystems) {
await repository.addOrUpdateSystem(system);
}
await repository.clearSystemData();
const result = await repository.getSystems();
expect(result).toEqual([]);
});
test("clears system data when the repository has data", async () => {
const mockSystems = generateMockSystems();
const system = mockSystems[0];
await repository.addOrUpdateSystem(system);
await repository.clearSystemData();
const result = await repository.getSystems();
expect(result).toEqual([]);
});
});
describe("clearShuttleData", () => {
test("clears all shuttles from the repository", async () => {
const mockShuttles = generateMockShuttles();
@@ -651,7 +580,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.clearShuttleData();
const result = await repository.getShuttlesBySystemId("sys1");
const result = await repository.getShuttles();
expect(result).toEqual([]);
});
});
@@ -693,7 +622,7 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.clearRouteData();
const result = await repository.getRoutesBySystemId("sys1");
const result = await repository.getRoutes();
expect(result).toEqual([]);
});
});
@@ -707,8 +636,8 @@ describe("UnoptimizedInMemoryRepository", () => {
await repository.clearStopData();
const result = await repository.getStopsBySystemId("sys1");
const result = await repository.getStops();
expect(result).toEqual([]);
});
});
});
});

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 { HistoricalParkingAverageQueryArguments } 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: HistoricalParkingAverageQueryArguments = {
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,39 @@
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.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,59 @@
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.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,61 @@ 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;
const shuttles = await system.shuttleRepository.getShuttlesByRouteId(parent.id);
return shuttles.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.map(({ routeId, stopId, position, systemId, updatedTime }) => ({
routeId,
stopId,
position,
systemId,
updatedTime,
}));
}
},
}
}

View File

@@ -5,7 +5,11 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
Shuttle: {
eta: async (parent, args, contextValue, info) => {
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.shuttleRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId);
if (etaForStopId === null) return null;
return {
@@ -13,22 +17,31 @@ 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);
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
const etasForShuttle = await system.shuttleRepository.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)) {
@@ -38,7 +51,10 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
return [];
},
route: async (parent, args, contextValue, info) => {
const route = await contextValue.repository.getRouteById(parent.routeId);
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 +62,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,19 @@ 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 [];
}
return await system.shuttleRepository.getOrderedStopsByStopId(parent.id);
},
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 [];
}
return await system.shuttleRepository.getEtasForStopId(parent.id);
},
},
}
}

View File

@@ -3,15 +3,30 @@ 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 [];
}
return await system.shuttleRepository.getRoutes();
},
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 [];
}
return await system.shuttleRepository.getStops();
},
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 +35,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 +55,42 @@ 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 [];
}
return await system.shuttleRepository.getShuttles();
},
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,38 @@
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";
} from "../../../testHelpers/repositorySetupHelpers";
import assert = require("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].shuttleRepository, 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 +84,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,157 @@
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);
});
});
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();
});
});
});

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from "@jest/globals";
import {
buildSystemForTesting,
setupTestServerContext,
setupTestServerHolder
} from "../../../testHelpers/apolloTestServerHelpers";
import assert = require("node:assert");
import { addMockShuttleToRepository, addMockStopToRepository } from "../../../testHelpers/repositorySetupHelpers";
import { ScheduledNotification } from "../../repositories/notifications/NotificationRepository";
// See Apollo documentation for integration test guide
// https://www.apollographql.com/docs/apollo-server/testing/testing
describe("QueryResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
describe("systems", () => {
it("returns systems from the repository", async () => {
const systems = context.systems;
const query = `
query GetSystems
{
systems {
name
}
}
`;
const response = await holder.testServer.executeOperation({
query,
}, {
contextValue: context
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.systems).toHaveLength(systems.length);
});
});
describe("system", () => {
const query = `
query GetSystem($id: ID!)
{
system(id: $id) {
name
}
}
`;
it("returns a system for an ID from the repository", async () => {
context.systems = [
buildSystemForTesting(),
buildSystemForTesting(),
];
context.findSystemById = (_: string) => context.systems[1];
context.systems[1].id = "test-id";
const systems = context.systems;
const systemToGet = systems[1];
const response = await holder.testServer.executeOperation({
query,
variables: {
id: systemToGet.id,
}
}, {
contextValue: context
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.system).toBeDefined();
});
it("returns null if there is no system", async () => {
context.findSystemById = (_: string) => null;
const response = await holder.testServer.executeOperation({
query,
variables: {
id: "nonexistent-id",
}
}, {
contextValue: context
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.system).toBeNull();
});
});
describe("isNotificationScheduled and secondsThresholdForNotification", () => {
const query = `
query IsNotificationScheduled($input: NotificationInput!) {
isNotificationScheduled(input: $input)
secondsThresholdForNotification(input: $input)
}
`;
it("returns correct data if the notification is scheduled", async () => {
// Arrange
const shuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, "1");
const stop = await addMockStopToRepository(context.systems[0].shuttleRepository, "1")
const notification: ScheduledNotification = {
shuttleId: shuttle.id,
stopId: stop.id,
deviceId: "1",
secondsThreshold: 240,
};
await context.systems[0].notificationRepository.addOrUpdateNotification(notification);
const notificationLookup: any = {
...notification,
}
delete notificationLookup.secondsThreshold;
// Act
const response = await holder.testServer.executeOperation({
query,
variables: {
input: notificationLookup,
}
}, {
contextValue: context,
});
// Assert
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.secondsThresholdForNotification).toEqual(240);
expect(response.body.singleResult.data?.isNotificationScheduled).toBe(true);
});
it("returns false/null data if the notification isn't scheduled", async () => {
// Act
const response = await holder.testServer.executeOperation({
query,
variables: {
input: {
shuttleId: "1",
stopId: "1",
deviceId: "1",
},
}
}, {
contextValue: context,
});
// Assert
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.isNotificationScheduled).toBe(false);
expect(response.body.singleResult.data?.secondsThresholdForNotification).toBe(null);
});
});
});

View File

@@ -1,27 +1,28 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import {
addMockRouteToRepository,
addMockStopToRepository,
addMockSystemToRepository
} from "../testHelpers/repositorySetupHelpers";
import { generateMockOrderedStops, generateMockShuttles } from "../testHelpers/mockDataGenerators";
import { IRoute, IStop, ISystem } from "../../src/entities/entities";
addMockStopToRepository
} from "../../../testHelpers/repositorySetupHelpers";
import { generateMockOrderedStops, generateMockShuttles } from "../../../testHelpers/mockDataGenerators";
import { IRoute, IStop } from "../../entities/ShuttleRepositoryEntities";
import assert = require("node:assert");
import { InterchangeSystem } from "../../entities/InterchangeSystem";
describe("RouteResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
let mockSystem: InterchangeSystem;
let mockRoute: IRoute;
let mockStop: IStop;
beforeEach(async () => {
mockSystem = await addMockSystemToRepository(context.repository);
mockSystem = context.systems[0];
const systemId = mockSystem.id;
mockRoute = await addMockRouteToRepository(context.repository, systemId);
mockStop = await addMockStopToRepository(context.repository, systemId);
mockRoute = await addMockRouteToRepository(context.systems[0].shuttleRepository, systemId);
mockStop = await addMockStopToRepository(context.systems[0].shuttleRepository, systemId);
});
@@ -40,16 +41,15 @@ describe("RouteResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
routeId: mockRoute.id,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
@@ -58,7 +58,7 @@ describe("RouteResolvers", () => {
const expectedShuttle = expectedShuttles[0];
expectedShuttle.systemId = mockSystem.id;
expectedShuttle.routeId = mockRoute.id;
await context.repository.addOrUpdateShuttle(expectedShuttle);
await context.systems[0].shuttleRepository.addOrUpdateShuttle(expectedShuttle);
const response = await getResponseForShuttlesQuery();
@@ -95,7 +95,7 @@ describe("RouteResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -103,9 +103,8 @@ describe("RouteResolvers", () => {
stopId: mockStop.id,
}
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
@@ -114,7 +113,7 @@ describe("RouteResolvers", () => {
const expectedOrderedStop = orderedStops[0];
expectedOrderedStop.stopId = mockStop.id;
expectedOrderedStop.routeId = mockRoute.id;
await context.repository.addOrUpdateOrderedStop(expectedOrderedStop);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(expectedOrderedStop);
const response = await getResponseForOrderedStopQuery();
@@ -131,9 +130,9 @@ describe("RouteResolvers", () => {
const expectedOrderedStop = orderedStops[0];
expectedOrderedStop.stopId = mockStop.id;
expectedOrderedStop.routeId = mockRoute.id;
await context.repository.addOrUpdateOrderedStop(expectedOrderedStop);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(expectedOrderedStop);
await context.repository.removeStopIfExists(mockStop.id);
await context.systems[0].shuttleRepository.removeStopIfExists(mockStop.id);
const response = await getResponseForOrderedStopQuery();
@@ -145,4 +144,60 @@ describe("RouteResolvers", () => {
expect(orderedStop).toBeNull();
});
});
});
describe("orderedStops", () => {
async function getResponseForOrderedStopsQuery() {
const query = `
query GetRouteOrderedStop($systemId: ID!, $routeId: ID!) {
system(id: $systemId) {
route(id: $routeId) {
orderedStops {
stopId
}
}
}
}
`;
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
routeId: mockRoute.id,
},
}, {
contextValue: context,
});
}
it("returns ordered stops using provided data", async () => {
let orderedStops = generateMockOrderedStops();
orderedStops = orderedStops.filter((stop) => stop.routeId === orderedStops[0].routeId);
await Promise.all(orderedStops.map(async (stop) => {
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(stop);
}))
const response = await getResponseForOrderedStopsQuery();
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const retrievedOrderedStops = (response.body.singleResult.data as any).system.route.orderedStops;
expect(retrievedOrderedStops).toHaveLength(orderedStops.length);
retrievedOrderedStops.map((retrievedStop: any) => {
expect(orderedStops.find((originalStop) => originalStop.stopId === retrievedStop.stopId)).not.toBeUndefined();
});
});
it("returns empty array if there are no ordered stops", async () => {
const response = await getResponseForOrderedStopsQuery();
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const retrievedOrderedStops = (response.body.singleResult.data as any).system.route.orderedStops;
expect(retrievedOrderedStops).toHaveLength(0);
});
});
});

View File

@@ -1,20 +1,22 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { generateMockEtas, generateMockRoutes } from "../testHelpers/mockDataGenerators";
import { IShuttle, ISystem } from "../../src/entities/entities";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { addMockShuttleToRepository, addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers";
import { generateMockEtas, generateMockRoutes } from "../../../testHelpers/mockDataGenerators";
import { IShuttle } from "../../entities/ShuttleRepositoryEntities";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import { addMockShuttleToRepository } from "../../../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
import { InterchangeSystem } from "../../entities/InterchangeSystem";
describe("ShuttleResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
let mockSystem: InterchangeSystem;
let mockShuttle: IShuttle;
beforeEach(async () => {
mockSystem = await addMockSystemToRepository(context.repository);
mockShuttle = await addMockShuttleToRepository(context.repository,
mockSystem = context.systems[0];
mockShuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository,
mockSystem.id);
});
@@ -23,7 +25,7 @@ describe("ShuttleResolvers", () => {
const etas = generateMockEtas();
await Promise.all(etas.map(async (eta) => {
eta.shuttleId = shuttleId;
await context.repository.addOrUpdateEta(eta);
await context.systems[0].shuttleRepository.addOrUpdateEta(eta);
}));
return etas;
}
@@ -47,7 +49,7 @@ describe("ShuttleResolvers", () => {
const mockEta = etas[1];
// Act
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -55,9 +57,8 @@ describe("ShuttleResolvers", () => {
stopId: mockEta.stopId,
},
}, {
contextValue: {
repository: context.repository,
},
contextValue: context
});
// Assert
@@ -68,7 +69,7 @@ describe("ShuttleResolvers", () => {
});
it("returns null if it doesn't exist", async () => {
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -76,9 +77,8 @@ describe("ShuttleResolvers", () => {
stopId: "nonexistent-stop",
}
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
// Assert
@@ -106,16 +106,15 @@ describe("ShuttleResolvers", () => {
it("returns associated ETAs if they exist for the shuttle", async () => {
const etas = await addMockEtas(mockShuttle.id);
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
shuttleId: mockShuttle.id,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
assert(response.body.kind === "single");
@@ -125,16 +124,15 @@ describe("ShuttleResolvers", () => {
});
it("returns empty array if no ETAs exist", async () => {
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
shuttleId: mockShuttle.id,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
assert(response.body.kind === "single");
@@ -164,22 +162,21 @@ describe("ShuttleResolvers", () => {
`
async function getResponseForQuery() {
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
shuttleId: mockShuttle.id,
}
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
it("returns the route if it exists", async () => {
const mockRoute = generateMockRoutes()[0];
await context.repository.addOrUpdateRoute(mockRoute);
await context.systems[0].shuttleRepository.addOrUpdateRoute(mockRoute);
const response = await getResponseForQuery();
@@ -197,4 +194,4 @@ describe("ShuttleResolvers", () => {
});
});
});
});

View File

@@ -1,32 +1,32 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { generateMockEtas, generateMockOrderedStops } from "../testHelpers/mockDataGenerators";
import { IStop, ISystem } from "../../src/entities/entities";
import { addMockStopToRepository, addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers";
import {
setupTestServerContext,
setupTestServerHolder
} from "../../../testHelpers/apolloTestServerHelpers";
import { generateMockEtas, generateMockOrderedStops } from "../../../testHelpers/mockDataGenerators";
import { IStop } from "../../entities/ShuttleRepositoryEntities";
import { addMockStopToRepository } from "../../../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
describe("StopResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockStop: IStop;
let mockSystem: ISystem;
beforeEach(async () => {
mockSystem = await addMockSystemToRepository(context.repository);
mockStop = await addMockStopToRepository(context.repository, mockSystem.id);
mockStop = await addMockStopToRepository(context.systems[0].shuttleRepository, context.systems[0].id);
})
async function getResponseForQuery(query: string) {
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
systemId: context.systems[0].id,
stopId: mockStop.id,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context,
});
}
@@ -50,7 +50,7 @@ describe("StopResolvers", () => {
mockOrderedStops = mockOrderedStops.filter((orderedStop) => orderedStop.stopId === mockOrderedStops[0].stopId);
await Promise.all(mockOrderedStops.map(async orderedStop => {
orderedStop.stopId = mockStop.id;
await context.repository.addOrUpdateOrderedStop(orderedStop);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(orderedStop);
}));
const response = await getResponseForQuery(query);
@@ -87,7 +87,7 @@ describe("StopResolvers", () => {
mockEtas = mockEtas.filter((eta) => eta.stopId === mockEtas[0].stopId);
await Promise.all(mockEtas.map(async eta => {
eta.stopId = mockStop.id;
await context.repository.addOrUpdateEta(eta);
await context.systems[0].shuttleRepository.addOrUpdateEta(eta);
}));
const response = await getResponseForQuery(query);
@@ -105,4 +105,4 @@ describe("StopResolvers", () => {
expect((response.body.singleResult.data as any).system.stop.etas).toHaveLength(0);
});
});
});
});

View File

@@ -1,35 +1,38 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../testHelpers/mockDataGenerators";
import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers";
import {
generateMockRoutes,
generateMockShuttles,
generateMockStops,
generateParkingStructures
} from "../../../testHelpers/mockDataGenerators";
import {
addMockRouteToRepository,
addMockShuttleToRepository,
addMockStopToRepository,
addMockSystemToRepository
} from "../testHelpers/repositorySetupHelpers";
import { ISystem } from "../../src/entities/entities";
} from "../../../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
import { InterchangeSystem } from "../../entities/InterchangeSystem";
describe("SystemResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
let mockSystem: InterchangeSystem;
beforeEach(async () => {
mockSystem = await addMockSystemToRepository(context.repository);
mockSystem = context.systems[0];
});
// TODO: Consolidate these into one single method taking an object
async function getResponseFromQueryNeedingSystemId(query: string) {
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
},
}, {
contextValue: {
repository: context.repository
},
contextValue: context,
});
}
@@ -49,7 +52,7 @@ describe("SystemResolvers", () => {
const expectedRoutes = generateMockRoutes();
await Promise.all(expectedRoutes.map(async (route) => {
route.systemId = mockSystem.id;
await context.repository.addOrUpdateRoute(route);
await context.systems[0].shuttleRepository.addOrUpdateRoute(route);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
@@ -77,7 +80,7 @@ describe("SystemResolvers", () => {
const expectedStops = generateMockStops();
await Promise.all(expectedStops.map(async (stop) => {
stop.systemId = mockSystem.id;
await context.repository.addOrUpdateStop(stop);
await context.systems[0].shuttleRepository.addOrUpdateStop(stop);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
@@ -102,21 +105,19 @@ describe("SystemResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
stopId: stopId,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context,
});
}
it("gets the stop with the correct id", async () => {
const mockStop = await addMockStopToRepository(context.repository, mockSystem.id);
const mockStop = await addMockStopToRepository(context.systems[0].shuttleRepository, mockSystem.id);
const response = await getResponseForStopQuery(mockStop.id);
@@ -132,9 +133,8 @@ describe("SystemResolvers", () => {
...mockSystem,
id: "2",
}
await context.repository.addOrUpdateSystem(updatedSystem);
const mockStop = await addMockStopToRepository(context.repository, updatedSystem.id);
const mockStop = await addMockStopToRepository(context.systems[0].shuttleRepository, updatedSystem.id);
const response = await getResponseForStopQuery(mockStop.id);
@@ -169,21 +169,19 @@ describe("SystemResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
routeId,
},
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
it("gets the route with the correct id", async () => {
const mockRoute = await addMockRouteToRepository(context.repository, mockSystem.id);
const mockRoute = await addMockRouteToRepository(context.systems[0].shuttleRepository, mockSystem.id);
const response = await getResponseForRouteQuery(mockRoute.id);
@@ -200,9 +198,8 @@ describe("SystemResolvers", () => {
...mockSystem,
id: "2",
}
await context.repository.addOrUpdateSystem(updatedSystem);
const mockRoute = await addMockRouteToRepository(context.repository, updatedSystem.id);
const mockRoute = await addMockRouteToRepository(context.systems[0].shuttleRepository, updatedSystem.id);
const response = await getResponseForRouteQuery(mockRoute.id);
@@ -237,21 +234,20 @@ describe("SystemResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
shuttleId: shuttleId,
}
}, {
contextValue: {
repository: context.repository,
}
contextValue: context
});
}
it("gets the shuttle with the correct id", async () => {
const mockShuttle = await addMockShuttleToRepository(context.repository, mockSystem.id);
const mockShuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, mockSystem.id);
const response = await getResponseForShuttleQuery(mockShuttle.id);
@@ -267,9 +263,8 @@ describe("SystemResolvers", () => {
...mockSystem,
id: "2",
}
await context.repository.addOrUpdateSystem(updatedSystem);
const mockShuttle = await addMockShuttleToRepository(context.repository, updatedSystem.id);
const mockShuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, updatedSystem.id);
const response = await getResponseForShuttleQuery(mockShuttle.id);
@@ -307,7 +302,7 @@ describe("SystemResolvers", () => {
const expectedShuttles = generateMockShuttles();
await Promise.all(expectedShuttles.map(async (shuttle) => {
shuttle.systemId = mockSystem.id;
await context.repository.addOrUpdateShuttle(shuttle);
await context.systems[0].shuttleRepository.addOrUpdateShuttle(shuttle);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
@@ -318,4 +313,5 @@ describe("SystemResolvers", () => {
expect(shuttles.length === expectedShuttles.length);
});
});
});
});

28
src/scalars/DateTime.ts Normal file
View File

@@ -0,0 +1,28 @@
import { GraphQLScalarType } from "graphql/type";
import { Kind } from "graphql/language";
// See Apollo documentation: https://www.apollographql.com/docs/apollo-server/schema/custom-scalars#providing-custom-scalars-to-apollo-server
export const DateTime = new GraphQLScalarType({
name: 'DateTime',
description: 'DateTime custom scalar type',
serialize(value) {
if (value instanceof Date) {
return value.getTime();
} else if (value instanceof Number) {
return value;
}
throw Error('GraphQL Date Scalar serializer expected a `Date` object or number');
},
parseValue(value) {
if (typeof value === 'number') {
return new Date(value);
}
throw new Error('GraphQL Date Scalar parser expected a `number`');
},
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return new Date(parseInt(ast.value, 10));
}
return null;
},
})

View File

@@ -1,70 +0,0 @@
import { GetterRepository } from "../repositories/GetterRepository";
import * as crypto from "node:crypto";
import jwt from "jsonwebtoken";
import fs from "fs";
interface ScheduledNotificationData {
deviceId: string;
shuttleId: string;
stopId: string;
}
export class NotificationService {
private token: string | undefined = undefined;
private _lastRefreshedTimeMs: number | undefined = undefined;
get lastRefreshedTimeMs() {
return this._lastRefreshedTimeMs;
}
constructor(private repository: GetterRepository) {}
private encryptionKey = crypto.randomBytes(32);
private iv = crypto.randomBytes(16);
public reloadAPNsTokenIfTimePassed() {
if (this.lastReloadedTimeForAPNsIsTooRecent()) {
return;
}
const keyId = process.env.APNS_KEY_ID;
const teamId = process.env.APNS_TEAM_ID;
const privateKeyPath = process.env.APNS_KEY_PATH;
if (!privateKeyPath) return;
const privateKey = fs.readFileSync(privateKeyPath);
const tokenHeader = {
alg: "ES256",
"kid": keyId,
};
const now = Date.now();
const claimsPayload = {
"iss": teamId,
"iat": now,
};
this.token = jwt.sign(claimsPayload, privateKey, {
algorithm: "ES256",
header: tokenHeader
});
this._lastRefreshedTimeMs = now;
}
private lastReloadedTimeForAPNsIsTooRecent() {
const thirtyMinutesMs = 1800000;
return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs < thirtyMinutesMs;
}
public async scheduleNotification({ deviceId, shuttleId, stopId }: ScheduledNotificationData) {
}
public async cancelNotification({ deviceId, shuttleId, stopId }: ScheduledNotificationData) {
}
public cancelAllNotifications() {
}
}

119
src/types/CircularQueue.ts Normal file
View File

@@ -0,0 +1,119 @@
export class CircularQueue<T> {
private startIndex: number;
private endIndex: number;
private _data: T[];
private _size: number;
private _capacity: number;
constructor(
size: number,
) {
// See the Mozilla documentation on sparse arrays (*not* undefined values)
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections#sparse_arrays
this._data = new Array<T>(size);
this.startIndex = 0;
this.endIndex = 0;
this._size = 0;
this._capacity = size;
}
size = (): number => this._size;
get = (index: number): T | undefined => {
if (index < 0 || index >= this._size) {
return undefined;
}
const actualIndex = (this.startIndex + index) % this._capacity;
return this._data[actualIndex];
};
appendWithSorting = (
data: T,
sortingCallback: (a: T, b: T) => number
) => {
if (this._size === 0) {
this._data[this.startIndex] = data;
this._size = 1;
this.endIndex = this.startIndex;
return;
}
const lastItem = this.get(this._size - 1);
const isAlreadyInOrder = lastItem && sortingCallback(lastItem, data) <= 0;
if (this._size < this._capacity) {
this.endIndex = (this.endIndex + 1) % this._capacity;
this._data[this.endIndex] = data;
this._size++;
} else {
this.startIndex = (this.startIndex + 1) % this._capacity;
this.endIndex = (this.endIndex + 1) % this._capacity;
this._data[this.endIndex] = data;
}
if (!isAlreadyInOrder) {
this.sortData(sortingCallback);
}
}
popFront = () => {
if (this._size === 0) {
return;
}
this._data[this.startIndex] = undefined as any;
if (this._size === 1) {
this._size = 0;
this.startIndex = 0;
this.endIndex = 0;
} else {
this.startIndex = (this.startIndex + 1) % this._capacity;
this._size--;
}
}
binarySearch = <K>(
searchKey: K,
keyExtractor: (item: T) => K
): T | undefined => {
if (this._size === 0) {
return undefined;
}
let left = 0;
let right = this._size - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const midItem = this.get(mid)!;
const midKey = keyExtractor(midItem);
if (midKey === searchKey) {
return midItem;
} else if (midKey < searchKey) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return undefined;
}
private sortData = (sortingCallback: (a: T, b: T) => number) => {
const items: T[] = [];
for (let i = 0; i < this._size; i++) {
const item = this.get(i);
if (item !== undefined) {
items.push(item);
}
}
items.sort(sortingCallback);
for (let i = 0; i < items.length; i++) {
const actualIndex = (this.startIndex + i) % this._capacity;
this._data[actualIndex] = items[i];
}
};
}

View File

@@ -16,4 +16,9 @@ export class TupleKey<T extends any[]> {
valueOf(): string {
return this.strKey;
}
static fromExistingStringKey(strKey: string) {
const tuple = strKey.split(separator);
return new TupleKey(...tuple);
}
}

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "@jest/globals";
import { CircularQueue } from "../CircularQueue";
interface TestItem {
id: number;
value: string;
}
describe("CircularQueue", () => {
const testItems = {
first: { id: 1, value: "first" },
second: { id: 2, value: "second" },
third: { id: 3, value: "third" },
fourth: { id: 4, value: "fourth" },
test: { id: 1, value: "test" },
apple: { id: 1, value: "apple" },
banana: { id: 2, value: "banana" },
cherry: { id: 3, value: "cherry" },
grape: { id: 5, value: "grape" },
orange: { id: 7, value: "orange" },
a: { id: 1, value: "a" },
b: { id: 2, value: "b" },
c: { id: 3, value: "c" },
d: { id: 4, value: "d" }
};
const sortingCallbacks = {
byId: (a: TestItem, b: TestItem) => a.id - b.id,
byValue: (a: TestItem, b: TestItem) => a.value.localeCompare(b.value)
};
const keyExtractors = {
id: (item: TestItem) => item.id,
value: (item: TestItem) => item.value
};
const createQueueWithItems = (size: number, items: TestItem[], sortingCallback: (a: TestItem, b: TestItem) => number) => {
const queue = new CircularQueue<TestItem>(size);
items.forEach(item => queue.appendWithSorting(item, sortingCallback));
return queue;
};
describe("constructor", () => {
it("creates queue with specified size", () => {
const queue = new CircularQueue<TestItem>(5);
expect(queue).toBeDefined();
});
});
describe("appendWithSorting", () => {
it("adds items to the queue with sorting callback", () => {
const queue = createQueueWithItems(3, [testItems.third, testItems.first, testItems.second], sortingCallbacks.byId);
expect(queue.size()).toBe(3);
expect(queue.get(0)).toEqual(testItems.first);
expect(queue.get(1)).toEqual(testItems.second);
expect(queue.get(2)).toEqual(testItems.third);
});
it("overwrites oldest items when queue is full", () => {
const queue = createQueueWithItems(2, [testItems.first, testItems.second, testItems.third], sortingCallbacks.byId);
expect(queue.size()).toBe(2);
});
it("handles appending to empty queue", () => {
const queue = createQueueWithItems(3, [testItems.test], sortingCallbacks.byId);
expect(queue.size()).toBe(1);
expect(queue.get(0)).toEqual(testItems.test);
});
it("optimizes append when items are already in order", () => {
const queue = new CircularQueue<TestItem>(5);
let sortCallCount = 0;
const trackingSortCallback = (a: TestItem, b: TestItem) => {
sortCallCount++;
return a.id - b.id;
};
queue.appendWithSorting(testItems.first, trackingSortCallback);
expect(sortCallCount).toBe(0);
queue.appendWithSorting(testItems.second, trackingSortCallback);
expect(sortCallCount).toBe(1);
queue.appendWithSorting(testItems.third, trackingSortCallback);
expect(sortCallCount).toBe(2);
queue.appendWithSorting({ id: 0, value: "zero" }, trackingSortCallback);
expect(sortCallCount).toBeGreaterThan(3);
expect(queue.get(0)).toEqual({ id: 0, value: "zero" });
expect(queue.get(1)).toEqual(testItems.first);
});
});
describe("popFront", () => {
it("removes the oldest item from queue", () => {
const queue = createQueueWithItems(3, [testItems.first, testItems.second], sortingCallbacks.byId);
expect(queue.size()).toBe(2);
queue.popFront();
expect(queue.size()).toBe(1);
expect(queue.get(0)).toEqual(testItems.second);
});
it("handles popping from empty queue", () => {
const queue = new CircularQueue<TestItem>(3);
expect(() => queue.popFront()).not.toThrow();
expect(queue.size()).toBe(0);
});
it("handles popping until empty", () => {
const queue = createQueueWithItems(2, [testItems.first, testItems.second], sortingCallbacks.byId);
queue.popFront();
expect(queue.size()).toBe(1);
queue.popFront();
expect(queue.size()).toBe(0);
queue.popFront();
expect(queue.size()).toBe(0);
});
});
describe("binarySearch", () => {
it("finds item using key extractor function", () => {
const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.grape, testItems.orange], sortingCallbacks.byId);
const result = queue.binarySearch(5, keyExtractors.id);
expect(result).toEqual(testItems.grape);
});
it("returns undefined when item not found", () => {
const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId);
const result = queue.binarySearch(5, keyExtractors.id);
expect(result).toBeUndefined();
});
it("finds first item", () => {
const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId);
const result = queue.binarySearch(1, keyExtractors.id);
expect(result).toEqual(testItems.apple);
});
it("finds last item", () => {
const queue = createQueueWithItems(5, [testItems.apple, testItems.cherry, testItems.orange], sortingCallbacks.byId);
const result = queue.binarySearch(7, keyExtractors.id);
expect(result).toEqual(testItems.orange);
});
it("returns undefined for empty queue", () => {
const queue = new CircularQueue<TestItem>(5);
const result = queue.binarySearch(1, keyExtractors.id);
expect(result).toBeUndefined();
});
it("works with string keys", () => {
const queue = createQueueWithItems(5, [testItems.apple, testItems.banana, testItems.cherry], sortingCallbacks.byValue);
const result = queue.binarySearch("banana", keyExtractors.value);
expect(result).toEqual(testItems.banana);
});
it("maintains sorted order assumption", () => {
const queue = createQueueWithItems(5, [testItems.d, testItems.a, testItems.c, testItems.b], sortingCallbacks.byValue);
expect(queue.binarySearch("a", keyExtractors.value)).toEqual(testItems.a);
expect(queue.binarySearch("b", keyExtractors.value)).toEqual(testItems.b);
expect(queue.binarySearch("c", keyExtractors.value)).toEqual(testItems.c);
expect(queue.binarySearch("d", keyExtractors.value)).toEqual(testItems.d);
expect(queue.binarySearch("z", keyExtractors.value)).toBeUndefined();
});
});
describe("integration", () => {
it("handles appendWithSorting, popFront, and binarySearch together", () => {
const queue = createQueueWithItems(3, [testItems.third, testItems.first, testItems.second], sortingCallbacks.byId);
expect(queue.binarySearch(2, keyExtractors.id)).toEqual(testItems.second);
queue.popFront();
expect(queue.binarySearch(1, keyExtractors.id)).toBeUndefined();
expect(queue.binarySearch(2, keyExtractors.id)).toEqual(testItems.second);
queue.appendWithSorting(testItems.fourth, sortingCallbacks.byId);
expect(queue.binarySearch(4, keyExtractors.id)).toEqual(testItems.fourth);
});
});
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { TupleKey } from "../../src/types/TupleKey";
import { TupleKey } from "../TupleKey";
describe("TupleKey", () => {
it("stores a value copy of the original tuple", () => {
@@ -28,4 +28,18 @@ describe("TupleKey", () => {
expect(sampleObject[tupleKey1.toString()]).toEqual("value1");
expect(sampleObject[(new TupleKey("1", "2")).toString()]).toEqual("value1");
});
});
describe("fromExistingStringKey", () => {
it("creates a new TupleKey from an existing string key", () => {
const strKey = "hello|there";
const tupleKey = TupleKey.fromExistingStringKey(strKey);
expect(tupleKey.toString()).toEqual(strKey);
});
it("creates an empty tuple if there is no string", () => {
const strKey = "";
const tupleKey = TupleKey.fromExistingStringKey(strKey);
expect(tupleKey.toString()).toEqual(strKey);
})
})
});

View File

@@ -1,279 +0,0 @@
import { beforeEach, describe, expect, it, jest, test } from "@jest/globals";
import { ApiBasedRepositoryLoader, ApiResponseError } from "../../src/loaders/ApiBasedRepositoryLoader";
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
import { fetchSystemDataSuccessfulResponse } from "../jsonSnapshots/fetchSystemData/fetchSystemDataSuccessfulResponse";
import { fetchSystemDataFailedResponse } from "../jsonSnapshots/fetchSystemData/fetchSystemDataFailedResponse";
import { fetchRouteDataSuccessfulResponse } from "../jsonSnapshots/fetchRouteData/fetchRouteDataSuccessfulResponse";
import {
fetchStopAndPolylineDataSuccessfulResponse
} from "../jsonSnapshots/fetchStopAndPolylineData/fetchStopAndPolylineDataSuccessfulResponse";
import { generateMockRoutes, generateMockShuttles, generateMockStops, generateMockSystems } from "../testHelpers/mockDataGenerators";
import {
fetchShuttleDataSuccessfulResponse
} from "../jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse";
import { fetchEtaDataSuccessfulResponse } from "../jsonSnapshots/fetchEtaData/fetchEtaDataSuccessfulResponse";
import {
resetGlobalFetchMockJson,
updateGlobalFetchMockJson,
updateGlobalFetchMockJsonToThrowSyntaxError
} from "../testHelpers/fetchMockHelpers";
async function assertAsyncCallbackThrowsApiResponseError(callback: () => Promise<any>) {
await expect(callback).rejects.toThrow(ApiResponseError);
}
describe("ApiBasedRepositoryLoader", () => {
let loader: ApiBasedRepositoryLoader;
beforeEach(() => {
loader = new ApiBasedRepositoryLoader(new UnoptimizedInMemoryRepository());
resetGlobalFetchMockJson();
});
describe("fetchAndUpdateSystemData", () => {
it("updates system data in repository if response received", async () => {
// Arrange
const systemsToPrune = generateMockSystems();
await Promise.all(systemsToPrune.map(async (system) => {
await loader.repository.addOrUpdateSystem(system);
}));
const numberOfSystemsInResponse = fetchSystemDataSuccessfulResponse.all.length;
updateGlobalFetchMockJson(fetchSystemDataSuccessfulResponse);
// Act
await loader.fetchAndUpdateSystemData();
// Assert
const systems = await loader.repository.getSystems();
if (loader.supportedSystemIds.length < numberOfSystemsInResponse) {
expect(systems).toHaveLength(loader.supportedSystemIds.length);
} else {
expect(systems).toHaveLength(numberOfSystemsInResponse);
}
});
it("throws the correct error if the API response contains no data", async () => {
updateGlobalFetchMockJson(fetchSystemDataFailedResponse);
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateSystemData();
});
});
it("throws the correct error if HTTP status code is not 200", async () => {
updateGlobalFetchMockJson(fetchSystemDataFailedResponse, 400);
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateSystemData();
});
});
});
describe("fetchAndUpdateRouteDataForExistingSystemsInRepository", () => {
test("calls fetchAndUpdateRouteDataForSystemId for all systems in repository", async () => {
const spy = jest.spyOn(loader, "fetchAndUpdateRouteDataForSystemId");
const systems = generateMockSystems();
await Promise.all(systems.map(async (system) => {
await loader.repository.addOrUpdateSystem(system);
}));
await loader.fetchAndUpdateRouteDataForExistingSystemsInRepository();
expect(spy.mock.calls.length).toBe(systems.length);
});
});
describe("fetchAndUpdateRouteDataForSystemId", () => {
const systemId = "263";
it("updates route data in repository if response received", async () => {
// Arrange
// Test pruning
const routesToPrune = generateMockRoutes();
await Promise.all(routesToPrune.map(async (route) => {
route.systemId = systemId;
await loader.repository.addOrUpdateRoute(route);
}));
updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse);
// Act
await loader.fetchAndUpdateRouteDataForSystemId(systemId);
// Assert
const routes = await loader.repository.getRoutesBySystemId(systemId);
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.fetchAndUpdateRouteDataForSystemId(systemId);
});
});
});
describe("fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository", () => {
it("calls fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId for every system", async () => {
const spy = jest.spyOn(loader, "fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId");
const systems = generateMockSystems();
await Promise.all(systems.map(async (system) => {
await loader.repository.addOrUpdateSystem(system);
}));
await loader.fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository();
expect(spy.mock.calls.length).toBe(systems.length);
});
})
describe("fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId", () => {
const systemId = "263";
it("updates stop and polyline data if response received", async () => {
// Arrange
// Test pruning of stops only
const stopsToPrune = generateMockStops();
await Promise.all(stopsToPrune.map(async (stop) => {
stop.systemId = systemId;
await loader.repository.addOrUpdateStop(stop);
}));
updateGlobalFetchMockJson(fetchStopAndPolylineDataSuccessfulResponse);
const stopsArray = Object.values(fetchStopAndPolylineDataSuccessfulResponse.stops);
await loader.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(systemId);
const stops = await loader.repository.getStopsBySystemId(systemId);
expect(stops.length).toEqual(stopsArray.length);
await Promise.all(stops.map(async (stop) => {
const orderedStops = await loader.repository.getOrderedStopsByStopId(stop.id)
expect(orderedStops.length).toBeGreaterThan(0);
}));
const routes = await loader.repository.getRoutesBySystemId(systemId);
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.fetchAndUpdateStopAndPolylineDataForRoutesWithSystemId(systemId);
});
})
});
describe("fetchAndUpdateShuttleDataForExistingSystemsInRepository", () => {
it("calls fetchAndUpdateShuttleDataForSystemId for every system", async () => {
const spy = jest.spyOn(loader, "fetchAndUpdateShuttleDataForSystemId");
const systems = generateMockSystems();
await Promise.all(systems.map(async (system) => {
await loader.repository.addOrUpdateSystem(system);
}))
await loader.fetchAndUpdateShuttleDataForExistingSystemsInRepository();
expect(spy.mock.calls.length).toBe(systems.length);
});
});
describe("fetchAndUpdateShuttleDataForSystemId", () => {
const systemId = "263";
it("updates shuttle data in repository if response received", async () => {
const shuttlesToPrune = generateMockShuttles();
await Promise.all(shuttlesToPrune.map(async (shuttle) => {
shuttle.systemId = systemId;
await loader.repository.addOrUpdateShuttle(shuttle);
}))
updateGlobalFetchMockJson(fetchShuttleDataSuccessfulResponse);
const busesInResponse = Object.values(fetchShuttleDataSuccessfulResponse.buses);
await loader.fetchAndUpdateShuttleDataForSystemId(systemId);
const shuttles = await loader.repository.getShuttlesBySystemId(systemId);
expect(shuttles.length).toEqual(busesInResponse.length);
});
it("throws the correct error if the API response contains no data", async () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateShuttleDataForSystemId(systemId);
});
});
});
describe("fetchAndUpdateEtaDataForExistingStopsForSystemsInRepository", () => {
it("calls fetchAndUpdateEtaDataFoExistingStopsForSystemId for every system in repository", async () => {
const spy = jest.spyOn(loader, "fetchAndUpdateEtaDataForExistingStopsForSystemId");
const systems = generateMockSystems();
await Promise.all(systems.map(async (system) => {
await loader.repository.addOrUpdateSystem(system);
}));
await loader.fetchAndUpdateEtaDataForExistingStopsForSystemsInRepository();
expect(spy.mock.calls.length).toBe(systems.length);
});
});
describe("fetchAndUpdateEtaDataForExistingStopsForSystemId", () => {
it("calls fetchAndUpdateEtaDataForStopId for every stop in repository", async () => {
const spy = jest.spyOn(loader, "fetchAndUpdateEtaDataForStopId");
const stops = generateMockStops();
stops.forEach((stop) => {
stop.systemId = "1";
});
await Promise.all(stops.map(async (stop) => {
await loader.repository.addOrUpdateStop(stop);
}));
await loader.fetchAndUpdateEtaDataForExistingStopsForSystemId("1");
expect(spy.mock.calls.length).toEqual(stops.length);
});
});
describe("fetchAndUpdateEtaDataForStopId", () => {
const stopId = "177666";
it("updates ETA data for stop id if response received", async () => {
updateGlobalFetchMockJson(fetchEtaDataSuccessfulResponse);
// @ts-ignore
const etasFromResponse = fetchEtaDataSuccessfulResponse.ETAs[stopId]
await loader.fetchAndUpdateEtaDataForStopId(stopId);
const etas = await loader.repository.getEtasForStopId(stopId);
expect(etas.length).toEqual(etasFromResponse.length);
});
it("throws the correct error if the API response contains no data", async () => {
updateGlobalFetchMockJsonToThrowSyntaxError();
await assertAsyncCallbackThrowsApiResponseError(async () => {
await loader.fetchAndUpdateEtaDataForStopId("263");
});
});
});
});

View File

@@ -1,67 +0,0 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
import { TimedApiBasedRepositoryLoader } from "../../src/loaders/TimedApiBasedRepositoryLoader";
import { resetGlobalFetchMockJson } from "../testHelpers/fetchMockHelpers";
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
describe("TimedApiBasedRepositoryLoader", () => {
let loader: TimedApiBasedRepositoryLoader;
let spies: any;
beforeAll(() => {
jest.useFakeTimers();
jest.spyOn(global, "setTimeout");
});
beforeEach(() => {
resetGlobalFetchMockJson();
loader = new TimedApiBasedRepositoryLoader(new UnoptimizedInMemoryRepository());
spies = {
fetchAndUpdateSystemData: jest.spyOn(loader, 'fetchAndUpdateSystemData'),
fetchAndUpdateRouteDataForExistingSystemsInRepository: jest.spyOn(loader, 'fetchAndUpdateRouteDataForExistingSystemsInRepository'),
fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository: jest.spyOn(loader, 'fetchAndUpdateStopAndPolylineDataForRoutesInExistingSystemsInRepository'),
fetchAndUpdateShuttleDataForExistingSystemsInRepository: jest.spyOn(loader, 'fetchAndUpdateShuttleDataForExistingSystemsInRepository'),
fetchAndUpdateEtaDataForExistingStopsForSystemsInRepository: jest.spyOn(loader, 'fetchAndUpdateEtaDataForExistingStopsForSystemsInRepository')
};
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 loader.start();
expect(loader["shouldBeRunning"]).toBe(true);
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalled();
});
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), loader.timeout);
expect(loader.timeout).not.toBeUndefined();
});
it("does nothing if timer is already running", async () => {
await loader.start();
await loader.start();
Object.values(spies).forEach((spy: any) => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
});
describe("stop", () => {
it("should update internal state", async () => {
loader.stop();
expect(loader['shouldBeRunning']).toBe(false);
});
});
});

View File

@@ -1,95 +0,0 @@
import { describe, expect, it } from "@jest/globals";
import { generateMockSystems } from "../testHelpers/mockDataGenerators";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import assert = require("node:assert");
// See Apollo documentation for integration test guide
// https://www.apollographql.com/docs/apollo-server/testing/testing
describe("QueryResolvers", () => {
const context = setupTestServerContext();
async function addMockSystems() {
const systems = generateMockSystems();
await Promise.all(systems.map(async (system) => {
await context.repository.addOrUpdateSystem(system);
}));
return systems;
}
describe("systems", () => {
it("returns systems from the repository", async () => {
const systems = await addMockSystems();
const query = `
query GetSystems
{
systems {
name
}
}
`;
const response = await context.testServer.executeOperation({
query,
}, {
contextValue: {
repository: context.repository,
},
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.systems).toHaveLength(systems.length);
});
});
describe("system", () => {
const query = `
query GetSystem($id: ID!)
{
system(id: $id) {
name
}
}
`;
it("returns a system for an ID from the repository", async () => {
const systems = await addMockSystems();
const systemToGet = systems[1];
const response = await context.testServer.executeOperation({
query,
variables: {
id: systemToGet.id,
}
}, {
contextValue: {
repository: context.repository,
}
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.system).toBeDefined();
});
it("returns null if there is no system", async () => {
const response = await context.testServer.executeOperation({
query,
variables: {
id: "nonexistent-id",
}
}, {
contextValue: {
repository: context.repository,
}
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
expect(response.body.singleResult.data?.system).toBeNull();
});
});
});

View File

@@ -1,71 +0,0 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { NotificationService } from "../../src/services/NotificationService";
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
import fs from "fs";
jest.mock("fs");
const sampleKey = `-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsrmSAZIagOfCP8sB
Wi2CBXG1Oo7v1bispIZCwIr4RDegCgYIKoZIzj0DAQehRANCAATZHxV2wQJLMBq+
ya+yfGi3g2ZUv6hrfe+j08ytekPHjXS0qzJoVELzKHa6EL9YAoZDXBtB6h+fGhXe
SOcONbaf
-----END PRIVATE KEY-----`
describe("NotificationService", () => {
let repository: UnoptimizedInMemoryRepository
let notificationService: NotificationService;
beforeEach(() => {
repository = new UnoptimizedInMemoryRepository();
notificationService = new NotificationService(repository);
// Ensure that tests don't hit the server
process.env = {
...process.env,
APNS_KEY_ID: "1",
APNS_TEAM_ID: "1",
APNS_KEY_PATH: "./dummy-path.p8"
};
(fs.readFileSync as jest.Mock).mockReturnValue(sampleKey);
})
describe("reloadAPNsTokenIfTimePassed", () => {
it("reloads the token if token hasn't been generated yet", async () => {
notificationService.reloadAPNsTokenIfTimePassed();
expect(notificationService.lastRefreshedTimeMs).toBeDefined();
});
it("doesn't reload the token if last refreshed time is recent", async () => {
notificationService.reloadAPNsTokenIfTimePassed();
const lastRefreshedTimeMs = notificationService.lastRefreshedTimeMs;
notificationService.reloadAPNsTokenIfTimePassed();
// Expect no change to have occurred
expect(lastRefreshedTimeMs).toEqual(notificationService.lastRefreshedTimeMs);
});
})
describe("scheduleNotification", () => {
it("sends a notification to given shuttle/stop ID when changed", async () => {
});
it("clears the notification after delivering successfully", async () => {
});
});
describe("cancelNotification", () => {
it("stops notification from sending to given shuttle/stop ID", async () => {
});
});
describe("cancelAllNotifications", () => {
it("clears all notifications scheduled to be sent", async () => {
});
})
});

View File

@@ -1,7 +0,0 @@
import { describe, expect, test } from "@jest/globals";
describe("sum", () => {
test("adds 1 + 2 to equal 3", () => {
expect(1 + 2).toBe(3);
});
});

View File

@@ -1,30 +0,0 @@
import { readFileSync } from "fs";
import { ApolloServer } from "@apollo/server";
import { MergedResolvers } from "../../src/MergedResolvers";
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
import { beforeEach } from "@jest/globals";
import { ServerContext } from "../../src/ServerContext";
function setUpTestServer() {
// Leaving this separate from the main server in case
// configuration changes
const typeDefs = readFileSync("./schema.graphqls", "utf8");
return new ApolloServer({
typeDefs,
resolvers: MergedResolvers,
});
}
export function setupTestServerContext() {
// @ts-ignore
const context: { testServer: ApolloServer<ServerContext>; repository: UnoptimizedInMemoryRepository } = {};
beforeEach(() => {
context.testServer = setUpTestServer();
context.repository = new UnoptimizedInMemoryRepository();
});
// Return a reference, not destructured values
return context;
}

View File

@@ -1,53 +0,0 @@
import { IEta, IOrderedStop, IRoute, IShuttle, IStop, ISystem } from "../../src/entities/entities";
// Use a single set of generators in case any of the
// interfaces change in the future
export function generateMockSystems(): ISystem[] {
return [
{ id: "1", name: "System A" },
{ id: "2", name: "System B" },
{ id: "3", name: "System C" },
];
}
export function generateMockShuttles(): IShuttle[] {
return [
{ id: "sh1", name: "Shuttle A", routeId: "r1", systemId: "sys1", coordinates: { latitude: 10, longitude: 20 } },
{ id: "sh2", name: "Shuttle B", routeId: "r2", systemId: "sys2", coordinates: { latitude: 15, longitude: 25 } },
{ id: "sh3", name: "Shuttle C", routeId: "r3", systemId: "sys3", coordinates: { latitude: 30, longitude: 40 } },
];
}
export function generateMockRoutes(): IRoute[] {
return [
{ id: "r1", name: "Route 1", color: "red", systemId: "sys1", polylineCoordinates: [] },
{ id: "r2", name: "Route 2", color: "blue", systemId: "sys2", polylineCoordinates: [] },
{ id: "r3", name: "Route 3", color: "green", systemId: "sys3", polylineCoordinates: [] },
];
}
export function generateMockStops(): IStop[] {
return [
{ id: "st1", name: "Stop A", systemId: "sys1", coordinates: { latitude: 10, longitude: 20 } },
{ id: "st2", name: "Stop B", systemId: "sys2", coordinates: { latitude: 15, longitude: 25 } },
{ id: "st3", name: "Stop C", systemId: "sys3", coordinates: { latitude: 30, longitude: 40 } },
];
}
export function generateMockOrderedStops(): IOrderedStop[] {
return [
{ stopId: "st1", routeId: "r1", position: 1 },
{ stopId: "st1", routeId: "r2", position: 2 },
{ stopId: "st2", routeId: "r1", position: 3 },
{ stopId: "st2", routeId: "r2", position: 4 },
];
}
export function generateMockEtas(): IEta[] {
return [
{ shuttleId: "sh1", stopId: "st1", secondsRemaining: 120 },
{ shuttleId: "sh1", stopId: "st2", secondsRemaining: 180 },
{ shuttleId: "sh2", stopId: "st3", secondsRemaining: 240 },
];
}

View File

@@ -0,0 +1,66 @@
import { readFileSync } from "fs";
import { ApolloServer } from "@apollo/server";
import { MergedResolvers } from "../src/MergedResolvers";
import { beforeEach } from "@jest/globals";
import { ServerContext } from "../src/ServerContext";
import { InterchangeSystem } from "../src/entities/InterchangeSystem";
import {
ChapmanApiBasedParkingRepositoryLoader
} from "../src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader";
function setUpTestServer() {
// Leaving this separate from the main server in case
// configuration changes
const typeDefs = readFileSync("./schema.graphqls", "utf8");
return new ApolloServer({
typeDefs,
resolvers: MergedResolvers,
});
}
const systemInfoForTesting = {
id: "1",
name: "Chapman University",
passioSystemId: "263",
parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id,
};
export function buildSystemForTesting() {
return InterchangeSystem.buildForTesting(
systemInfoForTesting,
);
}
/**
* Returns a `ServerContext` object which can be passed to requests
* for testing.
*/
export function setupTestServerContext() {
const context: { [key: string] : any } = {};
beforeEach(() => {
context.systems = [
buildSystemForTesting(),
];
context.findSystemById = (_: string) => context.systems[0];
});
return context as ServerContext;
}
/**
* Returns an object which holds a test server.
* This server is reset before every test.
* Tests should keep a reference to the holder object,
* and not destructure it.
*/
export function setupTestServerHolder() {
const holder: { [key: string]: any } = {};
beforeEach(() => {
holder.testServer = setUpTestServer();
});
return holder as { testServer: ApolloServer };
}

View File

@@ -0,0 +1,6 @@
import { expect } from "@jest/globals";
import { ApiResponseError } from "../src/loaders/ApiResponseError";
export async function assertAsyncCallbackThrowsApiResponseError(callback: () => Promise<any>) {
await expect(callback).rejects.toThrow(ApiResponseError);
}

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