Compare commits
492 Commits
chore/add-
...
76-impleme
| Author | SHA1 | Date | |
|---|---|---|---|
| c244a4b037 | |||
| e7b5f034f3 | |||
| 529f4e5869 | |||
| ca14fa6939 | |||
| 4830a449a1 | |||
| f49113e67b | |||
| 72b77c3b60 | |||
| 051f6c12e7 | |||
| 6901f6d45b | |||
| 5a183b632c | |||
| 07ccd05734 | |||
| 2c3d0a9fd4 | |||
| 8438d44ac3 | |||
| 9856719e38 | |||
| 8b8aae3ff9 | |||
| 627cb87d34 | |||
| 125081f40b | |||
| 5b9f0b9dea | |||
| 798350caed | |||
| eb8730de2a | |||
| 3b5a554b2f | |||
| 07b739cdde | |||
| eeb09dca5b | |||
| 7063f79647 | |||
| 33927f940f | |||
| b745d58ce0 | |||
| 14fbdc7408 | |||
| b7299b8359 | |||
| 5614fcae97 | |||
| 0fd8de13f9 | |||
| 6444251649 | |||
| 321e7a7fa9 | |||
| 883dc9ef6e | |||
| b737cd0fa5 | |||
| 2f46a0b07c | |||
| cc03803a3c | |||
| e78982538e | |||
| ee7b5eefda | |||
| 52a0133107 | |||
| 09ee17874d | |||
| 0a5a71d78f | |||
| 182587596c | |||
| 8ee1f1522e | |||
| ed037cf2d2 | |||
| 3302822bf8 | |||
| a76601d8cf | |||
| a543f37f77 | |||
| 5486046c24 | |||
| de3519f120 | |||
| e7488ee739 | |||
| ba700cb698 | |||
| 86710657df | |||
| d27b36a969 | |||
| 5db4a82535 | |||
| d1b60772d8 | |||
| f406e3e01d | |||
| 7c359dd775 | |||
| 5f982fe182 | |||
| df657a02f3 | |||
| b9d5f7b3df | |||
| 7f453157ee | |||
| 53632fe470 | |||
| 19336ce6ec | |||
| 868a9f3b1d | |||
| ca2a66509b | |||
| 8b5d80a9d6 | |||
| 2b04ca01a9 | |||
| 220b402b3b | |||
| db506e8b1f | |||
| 731ac4bafc | |||
| 4fbc30a264 | |||
| 18be03bfa6 | |||
| 4758d566da | |||
| 8fb296027d | |||
| 95fa610d77 | |||
| 6759bba2ce | |||
| 81272e94b1 | |||
| 1f252ee468 | |||
| ab29b08337 | |||
| 6400929eb6 | |||
| eca2ffe8ee | |||
| 28890ef8c4 | |||
| 721b66e6c1 | |||
| aae24d9920 | |||
| 2db983ef53 | |||
| f6f71526d7 | |||
| 2e5cb7e313 | |||
| f74376c8ed | |||
| 31e9c78ebd | |||
| 4b77a89ac2 | |||
| 06d2a7bb27 | |||
| 075b35e7ad | |||
| 9dcc17b687 | |||
| b068a69163 | |||
| a7c33c9479 | |||
| 1c7ee9fbc3 | |||
| b58babc7ea | |||
| f68dad0ca2 | |||
| b91f8ce151 | |||
| c21446b80a | |||
| ba82ce9f98 | |||
| bdb1da0bc4 | |||
| b02e2f721d | |||
| 6dae7959bf | |||
| ee4b11a308 | |||
| 570ab8a178 | |||
| 518044d860 | |||
| 563362b0b5 | |||
| d529554d84 | |||
| fe90ffe911 | |||
| f1dd8650b6 | |||
| 6e03dc7fe3 | |||
| 9969e93fc9 | |||
|
|
058b0d490f | ||
| bf06c7ed3a | |||
| b3cd399e10 | |||
| 4798af9674 | |||
| 12bdd4c631 | |||
| 95f2e6ec29 | |||
| 1cb498b150 | |||
| ba0375f582 | |||
| 44a0f56b93 | |||
| 26b1e70f5b | |||
| 7e74748139 | |||
| 02e440773a | |||
| 658222b91a | |||
| dd1a448160 | |||
| 544cab5324 | |||
| cb05d3ca03 | |||
| 2092827d37 | |||
| c9ecc18495 | |||
| a38ca3d3de | |||
| ea1150a98d | |||
| 37a67aace7 | |||
| 1624d78cb5 | |||
| a6d4b40fba | |||
| 2bace24623 | |||
| 776ffad162 | |||
| 267da3ff36 | |||
| 6f6bab7058 | |||
| c9aa2c401f | |||
| 0e3c12bebc | |||
| 50350d8c49 | |||
| 463f5cd582 | |||
| 44095e711a | |||
| 6cc20a4a6a | |||
| a0e0c19ca3 | |||
| d1a47baea6 | |||
| 16dce32887 | |||
| 49b823890f | |||
| c250e3ae5c | |||
| 2a01d007a9 | |||
| 6998ff529c | |||
| f67e3f13f5 | |||
| e25a96f704 | |||
| b6856a98a2 | |||
| 263607fc69 | |||
| 3d6dc1e7b6 | |||
| f6d8253719 | |||
| 8c810c5224 | |||
| 45df97f15c | |||
| aed005f7a7 | |||
| 726af52685 | |||
| b9810057ce | |||
| 110df5501d | |||
| dc639b1ca8 | |||
| aa7b21c4c0 | |||
| 461b1d1a59 | |||
| fe2b8b2f69 | |||
| e5c4387cff | |||
| 922fd7abb8 | |||
| b67b537a08 | |||
| e5b686f0be | |||
| 78514c5b8a | |||
| b2e6acfefa | |||
| be407fe220 | |||
| 6782654564 | |||
| 6cd7d38bfd | |||
| 0ce0c54ddd | |||
| 2741d229f9 | |||
| 51c2aedef6 | |||
| c7666c092c | |||
| f0c754908d | |||
| 4f855a8225 | |||
| b6f3e4ccde | |||
| 0076d987ca | |||
| 495a946e78 | |||
| 4f7d24acf0 | |||
| 059a9f5067 | |||
| 8e3765abfb | |||
| fe6db035f1 | |||
| 6767cc88a3 | |||
| 25f2a8c458 | |||
| 12f0a41153 | |||
| 34765a0f3b | |||
| e4ff597385 | |||
| 476b96c423 | |||
| 60256ce572 | |||
| 554898b097 | |||
| 96d4acb762 | |||
| 87dfd0dab1 | |||
| 0525ccc7e4 | |||
| 6e9478cd18 | |||
| bed2fbc7c3 | |||
| ea46115b86 | |||
| c8ea822626 | |||
| a830dadbee | |||
| 1e7082813d | |||
| d20711d53d | |||
| 2ba64a9b0d | |||
| d950156161 | |||
| 3d13bad11c | |||
| 329f21beb7 | |||
| 973867bd6d | |||
| 4b610c3ba9 | |||
| 9c414be8ce | |||
| dbb56f207d | |||
| c32dcb9610 | |||
| d95d112bf2 | |||
| f31e010828 | |||
| af57063b15 | |||
| f113dc1ec2 | |||
| 7136f9201d | |||
| 1629f79299 | |||
| 28c3e2f5f5 | |||
| 8a600565dc | |||
| 8dd2f02783 | |||
| ea4723df85 | |||
| 472b3a0b05 | |||
| bc598f0858 | |||
| 56466964c1 | |||
| 8c2fb3a52a | |||
| 95eb2c8f65 | |||
| 9ff388f2d4 | |||
| ed2b7dbe5e | |||
| 2d1e3c13d2 | |||
| 2d4bc6bdd4 | |||
| 940d172e87 | |||
| 6d762ce620 | |||
| db097b00dc | |||
| 2bc12aa794 | |||
| 95410470f6 | |||
| 5b695c7250 | |||
| 187800cf40 | |||
| d607a3742c | |||
| 4ea43ff21f | |||
| 503bb0fec7 | |||
| d584d1f58b | |||
| 5936c2b743 | |||
| a144657fa8 | |||
| 695fec1fce | |||
| 7e0c1f3539 | |||
|
|
af61bd4726 | ||
| d76740e5e7 | |||
|
|
1b6792a8c7 | ||
| 0e204af330 | |||
| fbc08838df | |||
| c6f846d109 | |||
| 50148cc2f4 | |||
| 39066b88bc | |||
| 9efd1d9289 | |||
| 372ecba952 | |||
| 3460f1becc | |||
| 998643dc04 | |||
| f34a2f27d7 | |||
| 7a5e1b8561 | |||
| a7ac9888f6 | |||
| 36359a4caa | |||
| cc6623404c | |||
| c98367f12e | |||
| bef2ce18fb | |||
| 1acd12d113 | |||
| a161a870d3 | |||
| c59ccd7f1a | |||
| a95c89c15b | |||
| ef94055133 | |||
| 02b3b77a61 | |||
| 75a4e133ed | |||
| 9c22e154be | |||
| a84cedd05a | |||
| 51d66d8886 | |||
| b0f04a9256 | |||
| b2fb430a38 | |||
| 2b28b94dbd | |||
| f2a2dd74f6 | |||
| ef94a9aa7e | |||
| a665c29745 | |||
| 3761f43909 | |||
| bda46d6808 | |||
| bba00eb067 | |||
| ae30660095 | |||
| fb58414ba3 | |||
| f007b72d94 | |||
| ead401a3b1 | |||
| 101c5ca6e0 | |||
| 7379840070 | |||
| c517d93e3a | |||
| 09be37cedb | |||
| 60b626b64f | |||
| 687fe0d826 | |||
| fab99db755 | |||
| 3a85f3da8b | |||
| 617f4dc72b | |||
| 7761e09b0e | |||
| 8ef161ae3b | |||
| f52b968d85 | |||
| 5d0916981f | |||
| de4cfaaa8d | |||
| e880830e02 | |||
| cb65cd49f3 | |||
| bef93538fb | |||
| 83cf38ed91 | |||
| 829b3f4ffd | |||
| 25f9dd62b1 | |||
| e12a9817ff | |||
| 299bcfcf18 | |||
| b442cdda21 | |||
| 892ca7ec63 | |||
| 4990e65c3e | |||
| 66642e7050 | |||
| f2a464d52d | |||
| 2dd91c9885 | |||
| 9942796f26 | |||
| 7199adf20e | |||
| 4d74027b0e | |||
| 717575e004 | |||
| 99672e749f | |||
| 96e7e0297b | |||
| 14a3738fba | |||
| 1321ff1347 | |||
| fb49577cf6 | |||
| 1f8d4bfbd4 | |||
| 50636e5582 | |||
| 07b80f2012 | |||
| 8874704acb | |||
| 9e6e20503d | |||
| 7e3305247d | |||
| 2ccdba3d11 | |||
| 20282c3cf9 | |||
| ac65e23984 | |||
| e433662f19 | |||
| 5670efb042 | |||
| 6251f0e247 | |||
| a6ceeb4dfb | |||
| 26cdab9907 | |||
| 23dc4c3f59 | |||
| 9869632a88 | |||
| 7f1bf005c1 | |||
| 83766c90c5 | |||
| a58780a37d | |||
| 619ef9a27f | |||
|
|
eaa23d635c | ||
| 049ea731a3 | |||
| d8ec54919e | |||
| 135a294e5d | |||
| 889e302912 | |||
| 891bf522ba | |||
| 024a7dd30c | |||
| 919832e86f | |||
| c0b7d99e8e | |||
| d9d11e1b31 | |||
| 217fb065cb | |||
| 371fb25faf | |||
| e8d5dbe6b3 | |||
| ad07a6be44 | |||
| 614b062b01 | |||
| bc2f90e11e | |||
| 58bb8a1c32 | |||
| f1b9fb6824 | |||
| 92537a298b | |||
| 63a215ac41 | |||
| 556906d15e | |||
| 6da782fd3d | |||
| 3b173aeb22 | |||
| 11871da802 | |||
| 11934da0af | |||
| 8794ced2dc | |||
| d8091bf148 | |||
| 5c49221783 | |||
| fdc62563b2 | |||
| ed2f2d42f3 | |||
| f5d40ebd7a | |||
| e22537d93d | |||
| 8971e3514d | |||
| e697fd89b8 | |||
| c09d35b65c | |||
| c1ec50db7b | |||
| bb77aca4ed | |||
| b4f1f1b87d | |||
| 420682dbb8 | |||
| 3ce72d79be | |||
| 19b08d1c05 | |||
| 2d4e206739 | |||
| a74a6c995a | |||
| bf1290bbc8 | |||
| d80df1de23 | |||
| ebcd35b85d | |||
|
|
9af48be746 | ||
| 6d1a85c2b4 | |||
| 4fdf60f9bf | |||
| 764f6e35f0 | |||
| b5954e251d | |||
| a880d71c87 | |||
| 5e186c643c | |||
| baa94eeef5 | |||
| da224c36ed | |||
| 2d92370254 | |||
| 6635a1a89b | |||
| a6138b37cb | |||
| 7e764502a0 | |||
| a3efae9f24 | |||
| 4351c6d454 | |||
| d3c824be68 | |||
| 4c4bd0fc4a | |||
| 2b0bef876c | |||
| fa40d15f5a | |||
| e667e89556 | |||
| 71698da173 | |||
| acc4d08716 | |||
| d7b15812f3 | |||
| 1627bc39fe | |||
| 69e2748eec | |||
| 94c15f93dd | |||
| cea7b48323 | |||
| 3a51d15e63 | |||
| 5687f7f600 | |||
| 0b9759aff1 | |||
| 8ac71090e4 | |||
| dccee459c3 | |||
| b918bf7a67 | |||
| 814f2c6584 | |||
| c79a3eac73 | |||
| 43f0ef27fc | |||
| f1f1d5c971 | |||
| d54539e65b | |||
| 588d433fa3 | |||
| 2116387203 | |||
| d730937b29 | |||
| 82a70ea04e | |||
|
|
6c31346d65 | ||
| 9048d2f1bc | |||
| 4a63929cc1 | |||
| 629fe4dddd | |||
| 403bec7c63 | |||
| d1db9b3742 | |||
| 3e4f6e5440 | |||
| 80e976752b | |||
| 6473661607 | |||
| aff82a8185 | |||
| c593fe14e1 | |||
| 602ccf8139 | |||
| b3b4b71e22 | |||
| 11ea0518fe | |||
| 0cd7c2fe47 | |||
| 179fa07d36 | |||
| b252adb5d4 | |||
| 64931e7546 | |||
| 4c8658a3d9 | |||
| 16d2d4b4ef | |||
| d357af7509 | |||
| 103ddef523 | |||
| c5c0208371 | |||
| f602aa6c2c | |||
| 18402401e3 | |||
| 339148c890 | |||
| 711caa1d15 | |||
| 90e5fa200e | |||
| 8bc71d25fe | |||
| e96316aa1e | |||
| fba021d921 | |||
| 3bdc730c95 | |||
| 69525a643d | |||
| 737a61fd41 | |||
| e9c2c8a7bc | |||
| 1e82ec2697 | |||
| 11fe22ceea | |||
| fe233dfcbd | |||
| 6172a04427 | |||
| f8ff7f9cc9 | |||
| dbbb9c0169 | |||
| 7d762f17dc | |||
| fe16bc7124 | |||
| b30e4be895 | |||
| 9dd6e945c2 | |||
| a810a2e78f | |||
| 01cbad3a73 | |||
| 047ff3a56e | |||
| ec62b0c8bf | |||
| 899b953be5 | |||
| 5957010a7f | |||
| 345d4905fe | |||
| acfc91d3c1 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
21
.env.example
21
.env.example
@@ -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
12
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal 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
20
.github/ISSUE_TEMPLATE/enhancement.md
vendored
Normal 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
16
.github/ISSUE_TEMPLATE/generic-task.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: Generic task
|
||||
about: Task with multiple steps.
|
||||
title: ''
|
||||
labels:
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ]
|
||||
|
||||
## Documentation
|
||||
|
||||
-
|
||||
34
.github/workflows/test.yml
vendored
34
.github/workflows/test.yml
vendored
@@ -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
6
.gitignore
vendored
@@ -20,3 +20,9 @@ yarn-error.log*
|
||||
|
||||
# Testing
|
||||
/coverage/
|
||||
|
||||
# Keys
|
||||
private/
|
||||
|
||||
# JetBrains
|
||||
.idea
|
||||
|
||||
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@@ -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
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
6
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -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
8
.idea/modules.xml
generated
@@ -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>
|
||||
12
.idea/project-inter-server.iml
generated
12
.idea/project-inter-server.iml
generated
@@ -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
6
.idea/vcs.xml
generated
@@ -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
101
CLAUDE.md
Normal 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
4
Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /usr/src/app
|
||||
COPY . .
|
||||
EXPOSE 4000
|
||||
22
README.md
22
README.md
@@ -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
63
docker-compose.yml
Normal 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
|
||||
|
||||
@@ -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
4179
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -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
28
redis-stack.conf
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
11
src/__mocks__/environment.ts
Normal file
11
src/__mocks__/environment.ts
Normal 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;
|
||||
|
||||
187
src/entities/InterchangeSystem.ts
Normal file
187
src/entities/InterchangeSystem.ts
Normal 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 };
|
||||
}
|
||||
|
||||
}
|
||||
17
src/entities/ParkingRepositoryEntities.ts
Normal file
17
src/entities/ParkingRepositoryEntities.ts
Normal 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
|
||||
13
src/entities/SharedEntities.ts
Normal file
13
src/entities/SharedEntities.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface IEntityWithTimestamp {
|
||||
updatedTime: Date;
|
||||
}
|
||||
|
||||
export interface IEntityWithId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ICoordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
39
src/entities/ShuttleRepositoryEntities.ts
Normal file
39
src/entities/ShuttleRepositoryEntities.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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
24
src/environment.ts
Normal 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;
|
||||
84
src/index.ts
84
src/index.ts
@@ -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();
|
||||
|
||||
6
src/loaders/ApiResponseError.ts
Normal file
6
src/loaders/ApiResponseError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class ApiResponseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ApiResponseError";
|
||||
}
|
||||
}
|
||||
3
src/loaders/RepositoryLoader.ts
Normal file
3
src/loaders/RepositoryLoader.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface RepositoryLoader {
|
||||
fetchAndUpdateAll(): Promise<void>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
5
src/loaders/parking/ParkingRepositoryLoader.ts
Normal file
5
src/loaders/parking/ParkingRepositoryLoader.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { RepositoryLoader } from "../RepositoryLoader";
|
||||
|
||||
export interface ParkingRepositoryLoader extends RepositoryLoader {
|
||||
fetchAndUpdateParkingStructures(): Promise<void>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
15
src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts
Normal file
15
src/loaders/parking/buildParkingRepositoryLoaderIfExists.ts
Normal 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;
|
||||
}
|
||||
35
src/loaders/parking/loadParkingTestData.ts
Normal file
35
src/loaders/parking/loadParkingTestData.ts
Normal 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);
|
||||
}))
|
||||
}
|
||||
@@ -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 {
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/loaders/shuttle/ShuttleRepositoryLoader.ts
Normal file
9
src/loaders/shuttle/ShuttleRepositoryLoader.ts
Normal 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>;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
4515
src/loaders/shuttle/loadShuttleTestData.ts
Normal file
4515
src/loaders/shuttle/loadShuttleTestData.ts
Normal file
File diff suppressed because it is too large
Load Diff
11
src/loaders/supportedIntegrationTestSystems.ts
Normal file
11
src/loaders/supportedIntegrationTestSystems.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
99
src/notifications/schedulers/ETANotificationScheduler.ts
Normal file
99
src/notifications/schedulers/ETANotificationScheduler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
192
src/notifications/senders/AppleNotificationSender.ts
Normal file
192
src/notifications/senders/AppleNotificationSender.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
38
src/repositories/BaseRedisRepository.ts
Normal file
38
src/repositories/BaseRedisRepository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
144
src/repositories/notifications/InMemoryNotificationRepository.ts
Normal file
144
src/repositories/notifications/InMemoryNotificationRepository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/repositories/notifications/NotificationRepository.ts
Normal file
33
src/repositories/notifications/NotificationRepository.ts
Normal 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;
|
||||
}
|
||||
113
src/repositories/notifications/RedisNotificationRepository.ts
Normal file
113
src/repositories/notifications/RedisNotificationRepository.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
});
|
||||
});
|
||||
161
src/repositories/parking/InMemoryParkingRepository.ts
Normal file
161
src/repositories/parking/InMemoryParkingRepository.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
26
src/repositories/parking/ParkingGetterRepository.ts
Normal file
26
src/repositories/parking/ParkingGetterRepository.ts
Normal 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[]>;
|
||||
}
|
||||
12
src/repositories/parking/ParkingGetterSetterRepository.ts
Normal file
12
src/repositories/parking/ParkingGetterSetterRepository.ts
Normal 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;
|
||||
}
|
||||
204
src/repositories/parking/RedisParkingRepository.ts
Normal file
204
src/repositories/parking/RedisParkingRepository.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[]>;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
94
src/resolvers/MutationResolvers.ts
Normal file
94
src/resolvers/MutationResolvers.ts
Normal 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"
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
59
src/resolvers/ParkingStructureResolvers.ts
Normal file
59
src/resolvers/ParkingStructureResolvers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/resolvers/ParkingSystemResolvers.ts
Normal file
39
src/resolvers/ParkingSystemResolvers.ts
Normal 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;
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
187
src/resolvers/__tests__/MutationResolverTests.test.ts
Normal file
187
src/resolvers/__tests__/MutationResolverTests.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
157
src/resolvers/__tests__/ParkingSystemResolverTests.test.ts
Normal file
157
src/resolvers/__tests__/ParkingSystemResolverTests.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
165
src/resolvers/__tests__/QueryResolverTests.test.ts
Normal file
165
src/resolvers/__tests__/QueryResolverTests.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
28
src/scalars/DateTime.ts
Normal 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;
|
||||
},
|
||||
})
|
||||
@@ -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
119
src/types/CircularQueue.ts
Normal 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];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
201
src/types/__tests__/CircularQueue.test.ts
Normal file
201
src/types/__tests__/CircularQueue.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
})
|
||||
})
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
});
|
||||
})
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
66
testHelpers/apolloTestServerHelpers.ts
Normal file
66
testHelpers/apolloTestServerHelpers.ts
Normal 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 };
|
||||
}
|
||||
6
testHelpers/assertAsyncCallbackThrowsApiResponseError.ts
Normal file
6
testHelpers/assertAsyncCallbackThrowsApiResponseError.ts
Normal 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
Reference in New Issue
Block a user