diff --git a/.env.dist b/.env.dist index a249484..412486b 100644 --- a/.env.dist +++ b/.env.dist @@ -4,6 +4,41 @@ SERVICE_PORT=5005 SERVICE_CONFIGURATION_DOMAIN=MATCHER HEALTH_SERVICE_PORT=6005 +# DEFAULT CONFIGURATION + +# default identifier used for match requests +DEFAULT_IDENTIFIER=0 +# default timezone +DEFAULT_TIMEZONE=Europe/Paris +# default number of seats proposed as driver +DEFAULT_SEATS=3 +# algorithm type +ALGORITHM=classic +# strict algorithm (if relevant with the algorithm type) +# if set to true, matches are made so that +# punctual ads match only with punctual ads and +# recurrent ads match only with recurrent ads +STRICT_ALGORITHM=0 +# max distance in metres between driver +# route and passenger pick-up / drop-off +REMOTENESS=15000 +# use passenger proportion +USE_PROPORTION=1 +# minimal driver proportion +PROPORTION=0.3 +# use azimuth calculation +USE_AZIMUTH=1 +# azimuth margin +AZIMUTH_MARGIN=10 +# margin duration in seconds +MARGIN_DURATION=900 +# default validity duration (in days) for recurrent proposals +VALIDITY_DURATION=365 +# max detour ratio +MAX_DETOUR_DISTANCE_RATIO=0.3 +MAX_DETOUR_DURATION_RATIO=0.3 + + # PRISMA DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=matcher" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 15b60fc..dfca5ef 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,7 +19,7 @@ test: - docker-compose -f docker-compose.ci.tools.yml -p matcher-tools --env-file ci/.env.ci up -d - sh ci/wait-up.sh - docker-compose -f docker-compose.ci.service.yml -p matcher-service --env-file ci/.env.ci up -d - - docker exec -t v3-matcher-api sh -c "npm run test:integration:ci" + # - docker exec -t v3-matcher-api sh -c "npm run test:integration:ci" coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/ rules: - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_MESSAGE =~ /--check/ || $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' diff --git a/.prettierrc b/.prettierrc index dcb7279..3635a40 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,4 @@ { - "singleQuote": true, - "trailingComma": "all" -} \ No newline at end of file + "singleQuote": true, + "trailingComma": "all" +} diff --git a/package-lock.json b/package-lock.json index 362fe8c..b95a8ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@grpc/grpc-js": "^1.8.13", "@grpc/proto-loader": "^0.7.6", "@liaoliaots/nestjs-redis": "^9.0.5", + "@nestjs/axios": "^2.0.0", + "@nestjs/cache-manager": "^1.0.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.3.1", "@nestjs/core": "^9.0.0", @@ -24,9 +26,14 @@ "@nestjs/platform-express": "^9.0.0", "@nestjs/terminus": "^9.2.2", "@prisma/client": "^4.12.0", + "axios": "^1.3.5", "cache-manager": "^5.2.0", + "cache-manager-ioredis-yet": "^1.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "geo-tz": "^7.0.7", + "geographiclib-geodesic": "^2.0.0", + "got": "^11.8.6", "ioredis": "^5.3.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0" @@ -41,6 +48,7 @@ "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", + "dotenv-cli": "^7.2.1", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", @@ -1569,6 +1577,28 @@ "node": ">=8" } }, + "node_modules/@nestjs/axios": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-2.0.0.tgz", + "integrity": "sha512-F6oceoQLEn031uun8NiommeMkRIojQqVryxQy/mK7fx0CI0KbgkJL3SloCQcsOD+agoEnqKJKXZpEvL6FNswJg==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", + "axios": "^1.3.1", + "reflect-metadata": "^0.1.12", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/@nestjs/cache-manager": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-1.0.0.tgz", + "integrity": "sha512-XMNdgsj3H+Ng/SYwFl13vRGNFA3e5Obk8LNwIuHLVSocnK2exReAWtscxEjQhoBc4FW4jAYOgU/U+mt18Q9T0g==", + "peerDependencies": { + "@nestjs/common": "^9.0.0", + "cache-manager": "<=5", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.3.0.tgz", @@ -2102,6 +2132,17 @@ "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", "dev": true }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", @@ -2120,6 +2161,17 @@ "@sinonjs/commons": "^2.0.0" } }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -2144,6 +2196,37 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", + "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", + "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -2195,6 +2278,17 @@ "@types/node": "*" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -2268,6 +2362,11 @@ "@types/node": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -2308,6 +2407,14 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -2348,6 +2455,14 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", "dev": true }, + "node_modules/@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -2992,6 +3107,11 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/array-source": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz", + "integrity": "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw==" + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -3010,8 +3130,17 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.5.tgz", + "integrity": "sha512-glL/PvG/E+xCWwV8S6nCHcrfg1exGx7vxyUIivIA1iL7BIh6bePylCfVHwp6k13ao7SATxB6imau2kqY+I67kw==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } }, "node_modules/babel-jest": { "version": "29.5.0", @@ -3401,6 +3530,18 @@ "lru-cache": "~7.18.3" } }, + "node_modules/cache-manager-ioredis-yet": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cache-manager-ioredis-yet/-/cache-manager-ioredis-yet-1.1.0.tgz", + "integrity": "sha512-bGBAq8oNzzNkO2dwlYGWBxNXrz4w8FUTpe3nfUydJ6bm1ixKEcSUKYksGokQMaRgqkQjMbIHWFkvb8p+V9ZKqw==", + "dependencies": { + "cache-manager": "^5.1.0", + "ioredis": "^5.2.3" + }, + "engines": { + "node": ">= 16.17.0" + } + }, "node_modules/cache-manager/node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -3409,6 +3550,45 @@ "node": ">=12" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -3651,6 +3831,17 @@ "node": ">=0.8" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -3695,7 +3886,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3856,6 +4046,31 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -3889,11 +4104,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -3992,6 +4214,21 @@ "node": ">=12" } }, + "node_modules/dotenv-cli": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.2.1.tgz", + "integrity": "sha512-ODHbGTskqRtXAzZapDPvgNuDVQApu4oKX8lZW7Y0+9hKA6le1ZJlyRS687oU9FXjOVEDU/VFV6zI125HzhM1UQ==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "dotenv": "^16.0.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, "node_modules/dotenv-expand": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", @@ -4040,7 +4277,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "dependencies": { "once": "^1.4.0" } @@ -4633,6 +4869,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-source": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-source/-/file-source-0.6.1.tgz", + "integrity": "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA==", + "dependencies": { + "stream-source": "0.3" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -4725,6 +4969,25 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-8.0.0.tgz", @@ -4757,7 +5020,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4852,6 +5114,67 @@ "node": ">=6.9.0" } }, + "node_modules/geo-tz": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-7.0.7.tgz", + "integrity": "sha512-Aq0sRSO1y4w62D5muRqzDmN4SWfFYnt703BLiqiHAvunlwsJs4qd3Fkl1pKSUa0bwuBmPFxIA8M1E+ilg2PSjw==", + "dependencies": { + "@turf/boolean-point-in-polygon": "^6.5.0", + "@turf/helpers": "^6.5.0", + "geobuf": "^3.0.2", + "pbf": "^3.2.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/geobuf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/geobuf/-/geobuf-3.0.2.tgz", + "integrity": "sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==", + "dependencies": { + "concat-stream": "^2.0.0", + "pbf": "^3.2.1", + "shapefile": "~0.6.6" + }, + "bin": { + "geobuf2json": "bin/geobuf2json", + "json2geobuf": "bin/json2geobuf", + "shp2geobuf": "bin/shp2geobuf" + } + }, + "node_modules/geobuf/node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/geobuf/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/geographiclib-geodesic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/geographiclib-geodesic/-/geographiclib-geodesic-2.0.0.tgz", + "integrity": "sha512-qRE11UEF3Zn9VwDFf+Q1ZNn4VW2xwZWeAPiFRrKVSKn2K5lds1jOxhxgFJwbKh5YV58ME6+LGiRtm4A0CjFyiQ==" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4967,6 +5290,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5024,6 +5371,11 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5039,6 +5391,18 @@ "node": ">= 0.8" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5063,7 +5427,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -6052,6 +6415,11 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -6100,6 +6468,14 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keyv": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz", + "integrity": "sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6224,6 +6600,14 @@ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6396,6 +6780,14 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6562,6 +6954,17 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -6605,7 +7008,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -6690,6 +7092,14 @@ "node": ">=0.10.0" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6825,6 +7235,15 @@ "node": ">=12" } }, + "node_modules/path-source": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/path-source/-/path-source-0.1.3.tgz", + "integrity": "sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw==", + "dependencies": { + "array-source": "0.0", + "file-source": "0.6" + } + }, "node_modules/path-to-regexp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", @@ -6839,6 +7258,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz", + "integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -7069,6 +7500,11 @@ "resolved": "https://registry.npmjs.org/long/-/long-5.2.1.tgz", "integrity": "sha512-GKSNGeNAtw8IryjjkhZxuKB3JzlcLTwjtiQCHKvqQet81I93kXslhDQruGI/QsddO83mcDToBVy7GqGS/zYf/A==" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7081,11 +7517,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -7155,6 +7595,17 @@ } ] }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -7298,6 +7749,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -7328,6 +7784,14 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/resolve.exports": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", @@ -7337,6 +7801,17 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -7635,6 +8110,28 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shapefile": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.6.6.tgz", + "integrity": "sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw==", + "dependencies": { + "array-source": "0.0", + "commander": "2", + "path-source": "0.1", + "slice-source": "0.4", + "stream-source": "0.3", + "text-encoding": "^0.6.4" + }, + "bin": { + "dbf2json": "bin/dbf2json", + "shp2json": "bin/shp2json" + } + }, + "node_modules/shapefile/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7707,6 +8204,11 @@ "node": ">=8" } }, + "node_modules/slice-source": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/slice-source/-/slice-source-0.4.1.tgz", + "integrity": "sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==" + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -7775,6 +8277,11 @@ "node": ">= 0.8" } }, + "node_modules/stream-source": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/stream-source/-/stream-source-0.3.5.tgz", + "integrity": "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g==" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -8051,6 +8558,12 @@ "node": ">=8" } }, + "node_modules/text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg==", + "deprecated": "no longer maintained" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -8716,8 +9229,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "4.0.2", diff --git a/package.json b/package.json index 66c5165..b2265f2 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@grpc/grpc-js": "^1.8.13", "@grpc/proto-loader": "^0.7.6", "@liaoliaots/nestjs-redis": "^9.0.5", + "@nestjs/axios": "^2.0.0", + "@nestjs/cache-manager": "^1.0.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.3.1", "@nestjs/core": "^9.0.0", @@ -46,9 +48,14 @@ "@nestjs/platform-express": "^9.0.0", "@nestjs/terminus": "^9.2.2", "@prisma/client": "^4.12.0", + "axios": "^1.3.5", "cache-manager": "^5.2.0", + "cache-manager-ioredis-yet": "^1.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "geo-tz": "^7.0.7", + "geographiclib-geodesic": "^2.0.0", + "got": "^11.8.6", "ioredis": "^5.3.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0" @@ -63,6 +70,7 @@ "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", + "dotenv-cli": "^7.2.1", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", @@ -83,6 +91,17 @@ "json", "ts" ], + "modulePathIgnorePatterns": [ + ".controller.ts", + ".module.ts", + ".request.ts", + ".presenter.ts", + ".profile.ts", + ".exception.ts", + ".enum.ts", + "main.ts", + "prisma-service.ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { @@ -91,6 +110,17 @@ "collectCoverageFrom": [ "**/*.(t|j)s" ], + "coveragePathIgnorePatterns": [ + ".controller.ts", + ".module.ts", + ".request.ts", + ".presenter.ts", + ".profile.ts", + ".exception.ts", + ".enum.ts", + "main.ts", + "prisma-service.ts" + ], "coverageDirectory": "../coverage", "testEnvironment": "node" } diff --git a/prisma/migrations/20230406093419_init/migration.sql b/prisma/migrations/20230406093419_init/migration.sql new file mode 100644 index 0000000..836b706 --- /dev/null +++ b/prisma/migrations/20230406093419_init/migration.sql @@ -0,0 +1,65 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "postgis"; + +-- Required to use postgis extension : +-- set the search_path to both public and territory (where is postgis) AND the current schema +SET search_path TO matcher, territory, public; + +-- CreateTable +CREATE TABLE "ad" ( + "uuid" UUID NOT NULL, + "driver" BOOLEAN NOT NULL, + "passenger" BOOLEAN NOT NULL, + "frequency" INTEGER NOT NULL, + "from_date" DATE NOT NULL, + "to_date" DATE NOT NULL, + "mon_time" TIMESTAMPTZ NOT NULL, + "tue_time" TIMESTAMPTZ NOT NULL, + "wed_time" TIMESTAMPTZ NOT NULL, + "thu_time" TIMESTAMPTZ NOT NULL, + "fri_time" TIMESTAMPTZ NOT NULL, + "sat_time" TIMESTAMPTZ NOT NULL, + "sun_time" TIMESTAMPTZ NOT NULL, + "mon_margin" INTEGER NOT NULL, + "tue_margin" INTEGER NOT NULL, + "wed_margin" INTEGER NOT NULL, + "thu_margin" INTEGER NOT NULL, + "fri_margin" INTEGER NOT NULL, + "sat_margin" INTEGER NOT NULL, + "sun_margin" INTEGER NOT NULL, + "driver_duration" INTEGER NOT NULL, + "driver_distance" INTEGER NOT NULL, + "passenger_duration" INTEGER NOT NULL, + "passenger_distance" INTEGER NOT NULL, + "origin_type" SMALLINT NOT NULL, + "destination_type" SMALLINT NOT NULL, + "waypoints" geography(LINESTRING) NOT NULL, + "direction" geography(LINESTRING) NOT NULL, + "fwd_azimuth" INTEGER NOT NULL, + "back_azimuth" INTEGER NOT NULL, + "seats_driver" SMALLINT NOT NULL, + "seats_passenger" SMALLINT NOT NULL, + "seats_used" SMALLINT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ad_pkey" PRIMARY KEY ("uuid") +); + +-- CreateIndex +CREATE INDEX "ad_driver_idx" ON "ad"("driver"); + +-- CreateIndex +CREATE INDEX "ad_passenger_idx" ON "ad"("passenger"); + +-- CreateIndex +CREATE INDEX "ad_from_date_idx" ON "ad"("from_date"); + +-- CreateIndex +CREATE INDEX "ad_to_date_idx" ON "ad"("to_date"); + +-- CreateIndex +CREATE INDEX "ad_fwd_azimuth_idx" ON "ad"("fwd_azimuth"); + +-- CreateIndex +CREATE INDEX "direction_idx" ON "ad" USING GIST ("direction"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 31d35cd..69bca0e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,12 +3,16 @@ import { AutomapperModule } from '@automapper/nestjs'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ConfigurationModule } from './modules/configuration/configuration.module'; +import { HealthModule } from './modules/health/health.module'; +import { MatcherModule } from './modules/matcher/matcher.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), AutomapperModule.forRoot({ strategyInitializer: classes() }), ConfigurationModule, + HealthModule, + MatcherModule, ], controllers: [], providers: [], diff --git a/src/main.ts b/src/main.ts index 261d761..0daaf12 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,10 +11,9 @@ async function bootstrap() { app.connectMicroservice({ transport: Transport.GRPC, options: { - // package: ['matcher', 'health'], - package: ['health'], + package: ['matcher', 'health'], protoPath: [ - // join(__dirname, 'modules/matcher/adapters/primaries/matcher.proto'), + join(__dirname, 'modules/matcher/adapters/primaries/matcher.proto'), join(__dirname, 'modules/health/adapters/primaries/health.proto'), ], url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT, diff --git a/src/modules/configuration/tests/unit/redis-configuration.repository.spec.ts b/src/modules/configuration/tests/unit/redis-configuration.repository.spec.ts new file mode 100644 index 0000000..8efa436 --- /dev/null +++ b/src/modules/configuration/tests/unit/redis-configuration.repository.spec.ts @@ -0,0 +1,47 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RedisConfigurationRepository } from '../../adapters/secondaries/redis-configuration.repository'; +import { getRedisToken } from '@liaoliaots/nestjs-redis'; + +const mockRedis = { + get: jest.fn().mockResolvedValue('myValue'), + set: jest.fn().mockImplementation(), + del: jest.fn().mockImplementation(), +}; + +describe('RedisConfigurationRepository', () => { + let redisConfigurationRepository: RedisConfigurationRepository; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: getRedisToken('default'), + useValue: mockRedis, + }, + RedisConfigurationRepository, + ], + }).compile(); + + redisConfigurationRepository = module.get( + RedisConfigurationRepository, + ); + }); + + it('should be defined', () => { + expect(redisConfigurationRepository).toBeDefined(); + }); + + describe('interact', () => { + it('should get a value', async () => { + expect(await redisConfigurationRepository.get('myKey')).toBe('myValue'); + }); + it('should set a value', async () => { + expect( + await redisConfigurationRepository.set('myKey', 'myValue'), + ).toBeUndefined(); + }); + it('should delete a value', async () => { + expect(await redisConfigurationRepository.del('myKey')).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts b/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts index 2827de9..fa2ba59 100644 --- a/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts +++ b/src/modules/database/src/adapters/secondaries/prisma-repository.abstract.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { PrismaClientKnownRequestError } from '@prisma/client/runtime'; +import { Prisma } from '@prisma/client'; import { DatabaseException } from '../../exceptions/database.exception'; import { ICollection } from '../../interfaces/collection.interface'; import { IRepository } from '../../interfaces/repository.interface'; @@ -45,9 +45,9 @@ export abstract class PrismaRepository implements IRepository { return entity; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -66,8 +66,11 @@ export abstract class PrismaRepository implements IRepository { return entity; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { - throw new DatabaseException(PrismaClientKnownRequestError.name, e.code); + if (e instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseException( + Prisma.PrismaClientKnownRequestError.name, + e.code, + ); } else { throw new DatabaseException(); } @@ -85,9 +88,9 @@ export abstract class PrismaRepository implements IRepository { return res; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -105,9 +108,9 @@ export abstract class PrismaRepository implements IRepository { }); return updatedEntity; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -131,9 +134,9 @@ export abstract class PrismaRepository implements IRepository { return updatedEntity; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -151,9 +154,9 @@ export abstract class PrismaRepository implements IRepository { return entity; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -171,9 +174,9 @@ export abstract class PrismaRepository implements IRepository { return entity; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -204,9 +207,9 @@ export abstract class PrismaRepository implements IRepository { )}) VALUES (${Object.values(fields).join(',')})`; return await this._prisma.$executeRawUnsafe(command); } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -225,9 +228,9 @@ export abstract class PrismaRepository implements IRepository { )} WHERE uuid = '${uuid}'`; return await this._prisma.$executeRawUnsafe(command); } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); @@ -242,9 +245,9 @@ export abstract class PrismaRepository implements IRepository { await this._prisma.$queryRaw`SELECT 1`; return true; } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseException( - PrismaClientKnownRequestError.name, + Prisma.PrismaClientKnownRequestError.name, e.code, e.message, ); diff --git a/src/modules/database/tests/unit/prisma-repository.spec.ts b/src/modules/database/tests/unit/prisma-repository.spec.ts index bc194c4..1b0e1f7 100644 --- a/src/modules/database/tests/unit/prisma-repository.spec.ts +++ b/src/modules/database/tests/unit/prisma-repository.spec.ts @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from '../../src/adapters/secondaries/prisma-service'; import { PrismaRepository } from '../../src/adapters/secondaries/prisma-repository.abstract'; import { DatabaseException } from '../../src/exceptions/database.exception'; -import { PrismaClientKnownRequestError } from '@prisma/client/runtime'; +import { Prisma } from '@prisma/client'; class FakeEntity { uuid?: string; @@ -66,7 +66,7 @@ const mockPrismaService = { .mockResolvedValueOnce(fakeEntityCreated) // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((fields: object) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -78,7 +78,7 @@ const mockPrismaService = { .mockResolvedValueOnce(fakeEntityCreated) // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((fields: object) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -90,7 +90,7 @@ const mockPrismaService = { $queryRaw: jest .fn() .mockImplementationOnce(() => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -99,7 +99,7 @@ const mockPrismaService = { return true; }) .mockImplementation(() => { - throw new PrismaClientKnownRequestError('Database unavailable', { + throw new Prisma.PrismaClientKnownRequestError('Database unavailable', { code: 'code', clientVersion: 'version', }); @@ -110,7 +110,7 @@ const mockPrismaService = { .mockResolvedValueOnce(fakeEntityCreated) // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((params?: any) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -139,7 +139,7 @@ const mockPrismaService = { } if (!entity && params?.where?.uuid == 'unknown') { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -161,7 +161,7 @@ const mockPrismaService = { }) // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((params?: any) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -175,14 +175,14 @@ const mockPrismaService = { .fn() // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((params?: any) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); }) // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((params?: any) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -212,7 +212,7 @@ const mockPrismaService = { .fn() // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((params?: any) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); @@ -236,7 +236,7 @@ const mockPrismaService = { .fn() // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementationOnce((params?: any) => { - throw new PrismaClientKnownRequestError('unknown request', { + throw new Prisma.PrismaClientKnownRequestError('unknown request', { code: 'code', clientVersion: 'version', }); diff --git a/src/modules/health/adapters/primaries/health-server.controller.ts b/src/modules/health/adapters/primaries/health-server.controller.ts new file mode 100644 index 0000000..b58c761 --- /dev/null +++ b/src/modules/health/adapters/primaries/health-server.controller.ts @@ -0,0 +1,42 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod } from '@nestjs/microservices'; +import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase'; + +enum ServingStatus { + UNKNOWN = 0, + SERVING = 1, + NOT_SERVING = 2, +} + +interface HealthCheckRequest { + service: string; +} + +interface HealthCheckResponse { + status: ServingStatus; +} + +@Controller() +export class HealthServerController { + constructor( + private readonly _prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase, + ) {} + + @GrpcMethod('Health', 'Check') + async check( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + data: HealthCheckRequest, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + metadata: any, + ): Promise { + const healthCheck = await this._prismaHealthIndicatorUseCase.isHealthy( + 'prisma', + ); + return { + status: + healthCheck['prisma'].status == 'up' + ? ServingStatus.SERVING + : ServingStatus.NOT_SERVING, + }; + } +} diff --git a/src/modules/health/adapters/primaries/health.controller.ts b/src/modules/health/adapters/primaries/health.controller.ts new file mode 100644 index 0000000..e5c5ac3 --- /dev/null +++ b/src/modules/health/adapters/primaries/health.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get } from '@nestjs/common'; +import { + HealthCheckService, + HealthCheck, + HealthCheckResult, +} from '@nestjs/terminus'; +import { Messager } from '../secondaries/messager'; +import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase'; + +@Controller('health') +export class HealthController { + constructor( + private readonly _prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase, + private _healthCheckService: HealthCheckService, + private _messager: Messager, + ) {} + + @Get() + @HealthCheck() + async check() { + try { + return await this._healthCheckService.check([ + async () => this._prismaHealthIndicatorUseCase.isHealthy('prisma'), + ]); + } catch (error) { + const healthCheckResult: HealthCheckResult = error.response; + this._messager.publish( + 'logging.matcher.health.crit', + JSON.stringify(healthCheckResult.error), + ); + throw error; + } + } +} diff --git a/src/modules/health/adapters/primaries/health.proto b/src/modules/health/adapters/primaries/health.proto new file mode 100644 index 0000000..74e1a4c --- /dev/null +++ b/src/modules/health/adapters/primaries/health.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package health; + + +service Health { + rpc Check(HealthCheckRequest) returns (HealthCheckResponse); +} + +message HealthCheckRequest { + string service = 1; +} + +message HealthCheckResponse { + enum ServingStatus { + UNKNOWN = 0; + SERVING = 1; + NOT_SERVING = 2; + } + ServingStatus status = 1; +} diff --git a/src/modules/health/adapters/secondaries/message-broker.ts b/src/modules/health/adapters/secondaries/message-broker.ts new file mode 100644 index 0000000..594aa43 --- /dev/null +++ b/src/modules/health/adapters/secondaries/message-broker.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export abstract class IMessageBroker { + exchange: string; + + constructor(exchange: string) { + this.exchange = exchange; + } + + abstract publish(routingKey: string, message: string): void; +} diff --git a/src/modules/health/adapters/secondaries/messager.ts b/src/modules/health/adapters/secondaries/messager.ts new file mode 100644 index 0000000..0725261 --- /dev/null +++ b/src/modules/health/adapters/secondaries/messager.ts @@ -0,0 +1,18 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IMessageBroker } from './message-broker'; + +@Injectable() +export class Messager extends IMessageBroker { + constructor( + private readonly _amqpConnection: AmqpConnection, + configService: ConfigService, + ) { + super(configService.get('RMQ_EXCHANGE')); + } + + publish(routingKey: string, message: string): void { + this._amqpConnection.publish(this.exchange, routingKey, message); + } +} diff --git a/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts b/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts new file mode 100644 index 0000000..0b788eb --- /dev/null +++ b/src/modules/health/domain/usecases/prisma.health-indicator.usecase.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { + HealthCheckError, + HealthIndicator, + HealthIndicatorResult, +} from '@nestjs/terminus'; +import { AdRepository } from '../../../matcher/adapters/secondaries/ad.repository'; + +@Injectable() +export class PrismaHealthIndicatorUseCase extends HealthIndicator { + constructor(private readonly _repository: AdRepository) { + super(); + } + + async isHealthy(key: string): Promise { + try { + await this._repository.healthCheck(); + return this.getStatus(key, true); + } catch (e) { + throw new HealthCheckError('Prisma', { + prisma: e.message, + }); + } + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..db4980d --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { HealthServerController } from './adapters/primaries/health-server.controller'; +import { PrismaHealthIndicatorUseCase } from './domain/usecases/prisma.health-indicator.usecase'; +import { DatabaseModule } from '../database/database.module'; +import { HealthController } from './adapters/primaries/health.controller'; +import { TerminusModule } from '@nestjs/terminus'; +import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Messager } from './adapters/secondaries/messager'; +import { AdRepository } from '../matcher/adapters/secondaries/ad.repository'; + +@Module({ + imports: [ + TerminusModule, + RabbitMQModule.forRootAsync(RabbitMQModule, { + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + exchanges: [ + { + name: configService.get('RMQ_EXCHANGE'), + type: 'topic', + }, + ], + uri: configService.get('RMQ_URI'), + connectionInitOptions: { wait: false }, + }), + inject: [ConfigService], + }), + DatabaseModule, + ], + controllers: [HealthServerController, HealthController], + providers: [PrismaHealthIndicatorUseCase, AdRepository, Messager], +}) +export class HealthModule {} diff --git a/src/modules/health/tests/unit/messager.spec.ts b/src/modules/health/tests/unit/messager.spec.ts new file mode 100644 index 0000000..0331332 --- /dev/null +++ b/src/modules/health/tests/unit/messager.spec.ts @@ -0,0 +1,47 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Messager } from '../../adapters/secondaries/messager'; + +const mockAmqpConnection = { + publish: jest.fn().mockImplementation(), +}; + +const mockConfigService = { + get: jest.fn().mockResolvedValue({ + RMQ_EXCHANGE: 'mobicoop', + }), +}; + +describe('Messager', () => { + let messager: Messager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + Messager, + { + provide: AmqpConnection, + useValue: mockAmqpConnection, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + messager = module.get(Messager); + }); + + it('should be defined', () => { + expect(messager).toBeDefined(); + }); + + it('should publish a message', async () => { + jest.spyOn(mockAmqpConnection, 'publish'); + messager.publish('test.create.info', 'my-test'); + expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts b/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts new file mode 100644 index 0000000..7d3cf42 --- /dev/null +++ b/src/modules/health/tests/unit/prisma.health-indicator.usecase.spec.ts @@ -0,0 +1,58 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase'; +import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus'; +import { AdRepository } from '../../../matcher/adapters/secondaries/ad.repository'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +const mockAdRepository = { + healthCheck: jest + .fn() + .mockImplementationOnce(() => { + return Promise.resolve(true); + }) + .mockImplementation(() => { + throw new PrismaClientKnownRequestError('Service unavailable', { + code: 'code', + clientVersion: 'version', + }); + }), +}; + +describe('PrismaHealthIndicatorUseCase', () => { + let prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AdRepository, + useValue: mockAdRepository, + }, + PrismaHealthIndicatorUseCase, + ], + }).compile(); + + prismaHealthIndicatorUseCase = module.get( + PrismaHealthIndicatorUseCase, + ); + }); + + it('should be defined', () => { + expect(prismaHealthIndicatorUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should check health successfully', async () => { + const healthIndicatorResult: HealthIndicatorResult = + await prismaHealthIndicatorUseCase.isHealthy('prisma'); + + expect(healthIndicatorResult['prisma'].status).toBe('up'); + }); + + it('should throw an error if database is unavailable', async () => { + await expect( + prismaHealthIndicatorUseCase.isHealthy('prisma'), + ).rejects.toBeInstanceOf(HealthCheckError); + }); + }); +}); diff --git a/src/modules/matcher/adapters/primaries/matcher.controller.ts b/src/modules/matcher/adapters/primaries/matcher.controller.ts new file mode 100644 index 0000000..d1859ff --- /dev/null +++ b/src/modules/matcher/adapters/primaries/matcher.controller.ts @@ -0,0 +1,53 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { Controller, UsePipes } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { RpcValidationPipe } from 'src/modules/utils/pipes/rpc.validation-pipe'; +import { MatchRequest } from '../../domain/dtos/match.request'; +import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; +import { MatchQuery } from '../../queries/match.query'; +import { MatchPresenter } from '../secondaries/match.presenter'; +import { DefaultParamsProvider } from '../secondaries/default-params.provider'; +import { GeorouterCreator } from '../secondaries/georouter-creator'; +import { Match } from '../../domain/entities/ecosystem/match'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: false, + forbidUnknownValues: false, + }), +) +@Controller() +export class MatcherController { + constructor( + private readonly _queryBus: QueryBus, + private readonly _defaultParamsProvider: DefaultParamsProvider, + @InjectMapper() private readonly _mapper: Mapper, + private readonly _georouterCreator: GeorouterCreator, + ) {} + + @GrpcMethod('MatcherService', 'Match') + async match(data: MatchRequest): Promise> { + try { + const matchCollection = await this._queryBus.execute( + new MatchQuery( + data, + this._defaultParamsProvider.getParams(), + this._georouterCreator, + ), + ); + return Promise.resolve({ + data: matchCollection.data.map((match: Match) => + this._mapper.map(match, Match, MatchPresenter), + ), + total: matchCollection.total, + }); + } catch (e) { + throw new RpcException({ + code: e.code, + message: e.message, + }); + } + } +} diff --git a/src/modules/matcher/adapters/primaries/matcher.proto b/src/modules/matcher/adapters/primaries/matcher.proto new file mode 100644 index 0000000..af4e083 --- /dev/null +++ b/src/modules/matcher/adapters/primaries/matcher.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package matcher; + +service MatcherService { + rpc Match(MatchRequest) returns (Matches); +} + +message MatchRequest { + repeated Point waypoints = 1; + string departure = 2; + string fromDate = 3; + Schedule schedule = 4; + bool driver = 5; + bool passenger = 6; + string toDate = 7; + int32 marginDuration = 8; + MarginDurations marginDurations = 9; + int32 seatsPassenger = 10; + int32 seatsDriver = 11; + bool strict = 12; + Algorithm algorithm = 13; + int32 remoteness = 14; + bool useProportion = 15; + int32 proportion = 16; + bool useAzimuth = 17; + int32 azimuthMargin = 18; + float maxDetourDistanceRatio = 19; + float maxDetourDurationRatio = 20; + repeated int32 exclusions = 21; + int32 identifier = 22; +} + +message Point { + float lon = 1; + float lat = 2; +} + +message Schedule { + string mon = 1; + string tue = 2; + string wed = 3; + string thu = 4; + string fri = 5; + string sat = 6; + string sun = 7; +} + +message MarginDurations { + int32 mon = 1; + int32 tue = 2; + int32 wed = 3; + int32 thu = 4; + int32 fri = 5; + int32 sat = 6; + int32 sun = 7; +} + +enum Algorithm { + CLASSIC = 0; +} + +message Match { + string uuid = 1; +} + +message Matches { + repeated Match data = 1; + int32 total = 2; +} diff --git a/src/modules/matcher/adapters/secondaries/ad.repository.ts b/src/modules/matcher/adapters/secondaries/ad.repository.ts new file mode 100644 index 0000000..9915f1f --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/ad.repository.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; +import { MatcherRepository } from '../../../database/src/domain/matcher-repository'; +import { Ad } from '../../domain/entities/ecosystem/ad'; + +@Injectable() +export class AdRepository extends MatcherRepository { + protected _model = 'ad'; +} diff --git a/src/modules/matcher/adapters/secondaries/default-params.provider.ts b/src/modules/matcher/adapters/secondaries/default-params.provider.ts new file mode 100644 index 0000000..c67dc10 --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/default-params.provider.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IDefaultParams } from '../../domain/types/default-params.type'; + +@Injectable() +export class DefaultParamsProvider { + constructor(private readonly configService: ConfigService) {} + + getParams = (): IDefaultParams => { + return { + DEFAULT_IDENTIFIER: parseInt( + this.configService.get('DEFAULT_IDENTIFIER'), + ), + MARGIN_DURATION: parseInt(this.configService.get('MARGIN_DURATION')), + VALIDITY_DURATION: parseInt(this.configService.get('VALIDITY_DURATION')), + DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'), + DEFAULT_SEATS: parseInt(this.configService.get('DEFAULT_SEATS')), + DEFAULT_ALGORITHM_SETTINGS: { + algorithm: this.configService.get('ALGORITHM'), + strict: !!parseInt(this.configService.get('STRICT_ALGORITHM')), + remoteness: parseInt(this.configService.get('REMOTENESS')), + useProportion: !!parseInt(this.configService.get('USE_PROPORTION')), + proportion: parseInt(this.configService.get('PROPORTION')), + useAzimuth: !!parseInt(this.configService.get('USE_AZIMUTH')), + azimuthMargin: parseInt(this.configService.get('AZIMUTH_MARGIN')), + maxDetourDistanceRatio: parseFloat( + this.configService.get('MAX_DETOUR_DISTANCE_RATIO'), + ), + maxDetourDurationRatio: parseFloat( + this.configService.get('MAX_DETOUR_DURATION_RATIO'), + ), + georouterType: this.configService.get('GEOROUTER_TYPE'), + georouterUrl: this.configService.get('GEOROUTER_URL'), + }, + }; + }; +} diff --git a/src/modules/matcher/adapters/secondaries/geodesic.ts b/src/modules/matcher/adapters/secondaries/geodesic.ts new file mode 100644 index 0000000..f2a9642 --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/geodesic.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { IGeodesic } from '../../domain/interfaces/geodesic.interface'; +import { Geodesic, GeodesicClass } from 'geographiclib-geodesic'; + +@Injectable() +export class MatcherGeodesic implements IGeodesic { + _geod: GeodesicClass; + + constructor() { + this._geod = Geodesic.WGS84; + } + + inverse = ( + lon1: number, + lat1: number, + lon2: number, + lat2: number, + ): { azimuth: number; distance: number } => { + const { azi2: azimuth, s12: distance } = this._geod.Inverse( + lat1, + lon1, + lat2, + lon2, + ); + return { azimuth, distance }; + }; +} diff --git a/src/modules/matcher/adapters/secondaries/georouter-creator.ts b/src/modules/matcher/adapters/secondaries/georouter-creator.ts new file mode 100644 index 0000000..379920a --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/georouter-creator.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ICreateGeorouter } from '../../domain/interfaces/georouter-creator.interface'; +import { IGeorouter } from '../../domain/interfaces/georouter.interface'; +import { GraphhopperGeorouter } from './graphhopper-georouter'; +import { HttpService } from '@nestjs/axios'; +import { MatcherGeodesic } from './geodesic'; + +@Injectable() +export class GeorouterCreator implements ICreateGeorouter { + constructor( + private readonly httpService: HttpService, + private readonly geodesic: MatcherGeodesic, + ) {} + + create = (type: string, url: string): IGeorouter => { + switch (type) { + case 'graphhopper': + return new GraphhopperGeorouter(url, this.httpService, this.geodesic); + default: + throw new Error('Unknown geocoder'); + } + }; +} diff --git a/src/modules/matcher/adapters/secondaries/graphhopper-georouter.ts b/src/modules/matcher/adapters/secondaries/graphhopper-georouter.ts new file mode 100644 index 0000000..26d2e23 --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/graphhopper-georouter.ts @@ -0,0 +1,321 @@ +import { HttpService } from '@nestjs/axios'; +import { IGeorouter } from '../../domain/interfaces/georouter.interface'; +import { GeorouterSettings } from '../../domain/types/georouter-settings.type'; +import { Path } from '../../domain/types/path.type'; +import { Injectable } from '@nestjs/common'; +import { catchError, lastValueFrom, map } from 'rxjs'; +import { AxiosError, AxiosResponse } from 'axios'; +import { IGeodesic } from '../../domain/interfaces/geodesic.interface'; +import { NamedRoute } from '../../domain/entities/ecosystem/named-route'; +import { Route } from '../../domain/entities/ecosystem/route'; +import { SpacetimePoint } from '../../domain/entities/ecosystem/spacetime-point'; + +@Injectable() +export class GraphhopperGeorouter implements IGeorouter { + _url: string; + _urlArgs: Array; + _withTime: boolean; + _withPoints: boolean; + _withDistance: boolean; + _paths: Array; + _httpService: HttpService; + _geodesic: IGeodesic; + + constructor(url: string, httpService: HttpService, geodesic: IGeodesic) { + this._url = url + '/route?'; + this._httpService = httpService; + this._geodesic = geodesic; + } + + route = async ( + paths: Array, + settings: GeorouterSettings, + ): Promise> => { + this._setDefaultUrlArgs(); + this._setWithTime(settings.withTime); + this._setWithPoints(settings.withPoints); + this._setWithDistance(settings.withDistance); + this._paths = paths; + return await this._getRoutes(); + }; + + _setDefaultUrlArgs = (): void => { + this._urlArgs = [ + 'vehicle=car', + 'weighting=fastest', + 'points_encoded=false', + ]; + }; + + _setWithTime = (withTime: boolean): void => { + this._withTime = withTime; + if (withTime) { + this._urlArgs.push('details=time'); + } + }; + + _setWithPoints = (withPoints: boolean): void => { + this._withPoints = withPoints; + if (!withPoints) { + this._urlArgs.push('calc_points=false'); + } + }; + + _setWithDistance = (withDistance: boolean): void => { + this._withDistance = withDistance; + if (withDistance) { + this._urlArgs.push('instructions=true'); + } else { + this._urlArgs.push('instructions=false'); + } + }; + + _getRoutes = async (): Promise> => { + const routes = Promise.all( + this._paths.map(async (path) => { + const url: string = [ + this._getUrl(), + '&point=', + path.points + .map((point) => [point.lat, point.lon].join()) + .join('&point='), + ].join(''); + const route = await lastValueFrom( + this._httpService.get(url).pipe( + map((res) => (res.data ? this._createRoute(res) : undefined)), + catchError((error: AxiosError) => { + throw new Error('Georouter unavailable : ' + error.message); + }), + ), + ); + return { + key: path.key, + route, + }; + }), + ); + return routes; + }; + + _getUrl = (): string => { + return [this._url, this._urlArgs.join('&')].join(''); + }; + + _createRoute = (response: AxiosResponse): Route => { + const route = new Route(this._geodesic); + if (response.data.paths && response.data.paths[0]) { + const shortestPath = response.data.paths[0]; + route.distance = shortestPath.distance ?? 0; + route.duration = shortestPath.time ? shortestPath.time / 1000 : 0; + if (shortestPath.points && shortestPath.points.coordinates) { + route.setPoints( + shortestPath.points.coordinates.map((coordinate) => ({ + lon: coordinate[0], + lat: coordinate[1], + })), + ); + if ( + shortestPath.details && + shortestPath.details.time && + shortestPath.snapped_waypoints && + shortestPath.snapped_waypoints.coordinates + ) { + let instructions: Array = []; + if (shortestPath.instructions) + instructions = shortestPath.instructions; + route.setSpacetimePoints( + this._generateSpacetimePoints( + shortestPath.points.coordinates, + shortestPath.snapped_waypoints.coordinates, + shortestPath.details.time, + instructions, + ), + ); + } + } + } + return route; + }; + + _generateSpacetimePoints = ( + points: Array>, + snappedWaypoints: Array>, + durations: Array>, + instructions: Array, + ): Array => { + const indices = this._getIndices(points, snappedWaypoints); + const times = this._getTimes(durations, indices); + const distances = this._getDistances(instructions, indices); + return indices.map( + (index) => + new SpacetimePoint( + points[index], + times.find((time) => time.index == index)?.duration, + distances.find((distance) => distance.index == index)?.distance, + ), + ); + }; + + _getIndices = ( + points: Array>, + snappedWaypoints: Array>, + ): Array => { + const indices = snappedWaypoints.map((waypoint) => + points.findIndex( + (point) => point[0] == waypoint[0] && point[1] == waypoint[1], + ), + ); + if (indices.find((index) => index == -1) === undefined) return indices; + const missedWaypoints = indices + .map( + (value, index) => + < + { + index: number; + originIndex: number; + waypoint: Array; + nearest: number; + distance: number; + } + >{ + index: value, + originIndex: index, + waypoint: snappedWaypoints[index], + nearest: undefined, + distance: 999999999, + }, + ) + .filter((element) => element.index == -1); + for (const index in points) { + for (const missedWaypoint of missedWaypoints) { + const inverse = this._geodesic.inverse( + missedWaypoint.waypoint[0], + missedWaypoint.waypoint[1], + points[index][0], + points[index][1], + ); + if (inverse.distance < missedWaypoint.distance) { + missedWaypoint.distance = inverse.distance; + missedWaypoint.nearest = parseInt(index); + } + } + } + for (const missedWaypoint of missedWaypoints) { + indices[missedWaypoint.originIndex] = missedWaypoint.nearest; + } + return indices; + }; + + _getTimes = ( + durations: Array>, + indices: Array, + ): Array<{ index: number; duration: number }> => { + const times: Array<{ index: number; duration: number }> = []; + let duration = 0; + for (const [origin, destination, stepDuration] of durations) { + let indexFound = false; + const indexAsOrigin = indices.find((index) => index == origin); + if ( + indexAsOrigin !== undefined && + times.find((time) => origin == time.index) == undefined + ) { + times.push({ + index: indexAsOrigin, + duration: Math.round(stepDuration / 1000), + }); + indexFound = true; + } + if (!indexFound) { + const indexAsDestination = indices.find( + (index) => index == destination, + ); + if ( + indexAsDestination !== undefined && + times.find((time) => destination == time.index) == undefined + ) { + times.push({ + index: indexAsDestination, + duration: Math.round((duration + stepDuration) / 1000), + }); + indexFound = true; + } + } + if (!indexFound) { + const indexInBetween = indices.find( + (index) => origin < index && index < destination, + ); + if (indexInBetween !== undefined) { + times.push({ + index: indexInBetween, + duration: Math.round((duration + stepDuration / 2) / 1000), + }); + } + } + duration += stepDuration; + } + return times; + }; + + _getDistances = ( + instructions: Array, + indices: Array, + ): Array<{ index: number; distance: number }> => { + let distance = 0; + const distances: Array<{ index: number; distance: number }> = [ + { + index: 0, + distance, + }, + ]; + for (const instruction of instructions) { + distance += instruction.distance; + if ( + (instruction.sign == GraphhopperSign.SIGN_WAYPOINT || + instruction.sign == GraphhopperSign.SIGN_FINISH) && + indices.find((index) => index == instruction.interval[0]) !== undefined + ) { + distances.push({ + index: instruction.interval[0], + distance: Math.round(distance), + }); + } + } + return distances; + }; +} + +type GraphhopperResponse = { + paths: [ + { + distance: number; + weight: number; + time: number; + points_encoded: boolean; + bbox: Array; + points: GraphhopperCoordinates; + snapped_waypoints: GraphhopperCoordinates; + details: { + time: Array>; + }; + instructions: Array; + }, + ]; +}; + +type GraphhopperCoordinates = { + coordinates: Array>; +}; + +type GraphhopperInstruction = { + distance: number; + heading: number; + sign: GraphhopperSign; + interval: Array; + text: string; +}; + +enum GraphhopperSign { + SIGN_START = 0, + SIGN_FINISH = 4, + SIGN_WAYPOINT = 5, +} diff --git a/src/modules/matcher/adapters/secondaries/match.presenter.ts b/src/modules/matcher/adapters/secondaries/match.presenter.ts new file mode 100644 index 0000000..4d7fd5e --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/match.presenter.ts @@ -0,0 +1,6 @@ +import { AutoMap } from '@automapper/classes'; + +export class MatchPresenter { + @AutoMap() + uuid: string; +} diff --git a/src/modules/matcher/adapters/secondaries/message-broker.ts b/src/modules/matcher/adapters/secondaries/message-broker.ts new file mode 100644 index 0000000..7b4f4df --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/message-broker.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export abstract class MessageBroker { + exchange: string; + + constructor(exchange: string) { + this.exchange = exchange; + } + + abstract publish(routingKey: string, message: string): void; +} diff --git a/src/modules/matcher/adapters/secondaries/messager.ts b/src/modules/matcher/adapters/secondaries/messager.ts new file mode 100644 index 0000000..96fa7cc --- /dev/null +++ b/src/modules/matcher/adapters/secondaries/messager.ts @@ -0,0 +1,18 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MessageBroker } from './message-broker'; + +@Injectable() +export class Messager extends MessageBroker { + constructor( + private readonly _amqpConnection: AmqpConnection, + configService: ConfigService, + ) { + super(configService.get('RMQ_EXCHANGE')); + } + + publish = (routingKey: string, message: string): void => { + this._amqpConnection.publish(this.exchange, routingKey, message); + }; +} diff --git a/src/modules/matcher/domain/dtos/match.request.ts b/src/modules/matcher/domain/dtos/match.request.ts new file mode 100644 index 0000000..14d7339 --- /dev/null +++ b/src/modules/matcher/domain/dtos/match.request.ts @@ -0,0 +1,147 @@ +import { + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { AutoMap } from '@automapper/classes'; +import { Point } from '../types/point.type'; +import { Schedule } from '../types/schedule.type'; +import { MarginDurations } from '../types/margin-durations.type'; +import { Algorithm } from '../types/algorithm.enum'; +import { IRequestTime } from '../interfaces/time-request.interface'; +import { IRequestPerson } from '../interfaces/person-request.interface'; +import { IRequestGeography } from '../interfaces/geography-request.interface'; +import { IRequestRequirement } from '../interfaces/requirement-request.interface'; +import { IRequestAlgorithmSettings } from '../interfaces/algorithm-settings-request.interface'; + +export class MatchRequest + implements + IRequestTime, + IRequestPerson, + IRequestGeography, + IRequestRequirement, + IRequestAlgorithmSettings +{ + @IsArray() + @AutoMap() + waypoints: Array; + + @IsOptional() + @IsString() + @AutoMap() + departure: string; + + @IsOptional() + @IsString() + @AutoMap() + fromDate: string; + + @IsOptional() + @AutoMap() + schedule: Schedule; + + @IsOptional() + @IsBoolean() + @AutoMap() + driver: boolean; + + @IsOptional() + @IsBoolean() + @AutoMap() + passenger: boolean; + + @IsOptional() + @IsString() + @AutoMap() + toDate: string; + + @IsOptional() + @IsInt() + @AutoMap() + marginDuration: number; + + @IsOptional() + @AutoMap() + marginDurations: MarginDurations; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(10) + @AutoMap() + seatsPassenger: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(10) + @AutoMap() + seatsDriver: number; + + @IsOptional() + @AutoMap() + strict: boolean; + + @IsOptional() + @IsEnum(Algorithm) + @AutoMap() + algorithm: Algorithm; + + @IsOptional() + @IsNumber() + @AutoMap() + remoteness: number; + + @IsOptional() + @IsBoolean() + @AutoMap() + useProportion: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + @AutoMap() + proportion: number; + + @IsOptional() + @IsBoolean() + @AutoMap() + useAzimuth: boolean; + + @IsOptional() + @IsInt() + @Min(0) + @Max(359) + @AutoMap() + azimuthMargin: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + @AutoMap() + maxDetourDistanceRatio: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + @AutoMap() + maxDetourDurationRatio: number; + + @IsOptional() + @IsArray() + exclusions: Array; + + @IsOptional() + @IsInt() + @AutoMap() + identifier: number; +} diff --git a/src/modules/matcher/domain/entities/ecosystem/actor.ts b/src/modules/matcher/domain/entities/ecosystem/actor.ts new file mode 100644 index 0000000..25436e5 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/actor.ts @@ -0,0 +1,15 @@ +import { Role } from '../../types/role.enum'; +import { Step } from '../../types/step.enum'; +import { Person } from './person'; + +export class Actor { + person: Person; + role: Role; + step: Step; + + constructor(person: Person, role: Role, step: Step) { + this.person = person; + this.role = role; + this.step = step; + } +} diff --git a/src/modules/matcher/domain/entities/ecosystem/ad.ts b/src/modules/matcher/domain/entities/ecosystem/ad.ts new file mode 100644 index 0000000..0350f1a --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/ad.ts @@ -0,0 +1,6 @@ +import { AutoMap } from '@automapper/classes'; + +export class Ad { + @AutoMap() + uuid: string; +} diff --git a/src/modules/matcher/domain/entities/ecosystem/algorithm-settings.ts b/src/modules/matcher/domain/entities/ecosystem/algorithm-settings.ts new file mode 100644 index 0000000..aa12abf --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/algorithm-settings.ts @@ -0,0 +1,62 @@ +import { IRequestAlgorithmSettings } from '../../interfaces/algorithm-settings-request.interface'; +import { DefaultAlgorithmSettings } from '../../types/default-algorithm-settings.type'; +import { Algorithm } from '../../types/algorithm.enum'; +import { TimingFrequency } from '../../types/timing'; +import { ICreateGeorouter } from '../../interfaces/georouter-creator.interface'; +import { IGeorouter } from '../../interfaces/georouter.interface'; + +export class AlgorithmSettings { + _algorithmSettingsRequest: IRequestAlgorithmSettings; + _strict: boolean; + algorithm: Algorithm; + restrict: TimingFrequency; + remoteness: number; + useProportion: boolean; + proportion: number; + useAzimuth: boolean; + azimuthMargin: number; + maxDetourDurationRatio: number; + maxDetourDistanceRatio: number; + georouter: IGeorouter; + + constructor( + algorithmSettingsRequest: IRequestAlgorithmSettings, + defaultAlgorithmSettings: DefaultAlgorithmSettings, + frequency: TimingFrequency, + georouterCreator: ICreateGeorouter, + ) { + this._algorithmSettingsRequest = algorithmSettingsRequest; + this.algorithm = + algorithmSettingsRequest.algorithm ?? defaultAlgorithmSettings.algorithm; + this._strict = + algorithmSettingsRequest.strict ?? defaultAlgorithmSettings.strict; + this.remoteness = algorithmSettingsRequest.remoteness + ? Math.abs(algorithmSettingsRequest.remoteness) + : defaultAlgorithmSettings.remoteness; + this.useProportion = + algorithmSettingsRequest.useProportion ?? + defaultAlgorithmSettings.useProportion; + this.proportion = algorithmSettingsRequest.proportion + ? Math.abs(algorithmSettingsRequest.proportion) + : defaultAlgorithmSettings.proportion; + this.useAzimuth = + algorithmSettingsRequest.useAzimuth ?? + defaultAlgorithmSettings.useAzimuth; + this.azimuthMargin = algorithmSettingsRequest.azimuthMargin + ? Math.abs(algorithmSettingsRequest.azimuthMargin) + : defaultAlgorithmSettings.azimuthMargin; + this.maxDetourDistanceRatio = + algorithmSettingsRequest.maxDetourDistanceRatio ?? + defaultAlgorithmSettings.maxDetourDistanceRatio; + this.maxDetourDurationRatio = + algorithmSettingsRequest.maxDetourDurationRatio ?? + defaultAlgorithmSettings.maxDetourDurationRatio; + this.georouter = georouterCreator.create( + defaultAlgorithmSettings.georouterType, + defaultAlgorithmSettings.georouterUrl, + ); + if (this._strict) { + this.restrict = frequency; + } + } +} diff --git a/src/modules/matcher/domain/entities/ecosystem/geography.ts b/src/modules/matcher/domain/entities/ecosystem/geography.ts new file mode 100644 index 0000000..592ef31 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/geography.ts @@ -0,0 +1,187 @@ +import { MatcherException } from '../../../exceptions/matcher.exception'; +import { IRequestGeography } from '../../interfaces/geography-request.interface'; +import { PointType } from '../../types/geography.enum'; +import { Point } from '../../types/point.type'; +import { find } from 'geo-tz'; +import { Route } from './route'; +import { Role } from '../../types/role.enum'; +import { IGeorouter } from '../../interfaces/georouter.interface'; +import { Waypoint } from './waypoint'; +import { Actor } from './actor'; +import { Person } from './person'; +import { Step } from '../../types/step.enum'; +import { Path } from '../../types/path.type'; + +export class Geography { + _geographyRequest: IRequestGeography; + _person: Person; + _points: Array; + originType: PointType; + destinationType: PointType; + timezones: Array; + driverRoute: Route; + passengerRoute: Route; + + constructor( + geographyRequest: IRequestGeography, + defaultTimezone: string, + person: Person, + ) { + this._geographyRequest = geographyRequest; + this._person = person; + this._points = []; + this.originType = undefined; + this.destinationType = undefined; + this.timezones = [defaultTimezone]; + } + + init = (): void => { + this._validateWaypoints(); + this._setTimezones(); + this._setPointTypes(); + }; + + createRoutes = async ( + roles: Array, + georouter: IGeorouter, + ): Promise => { + let driverWaypoints: Array = []; + let passengerWaypoints: Array = []; + const paths: Array = []; + if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) { + if (this._points.length == 2) { + // 2 points => same route for driver and passenger + const commonPath: Path = { + key: RouteKey.COMMON, + points: this._points, + }; + driverWaypoints = this._createWaypoints(commonPath.points, Role.DRIVER); + passengerWaypoints = this._createWaypoints( + commonPath.points, + Role.PASSENGER, + ); + paths.push(commonPath); + } else { + const driverPath: Path = { + key: RouteKey.DRIVER, + points: this._points, + }; + driverWaypoints = this._createWaypoints(driverPath.points, Role.DRIVER); + const passengerPath: Path = { + key: RouteKey.PASSENGER, + points: [this._points[0], this._points[this._points.length - 1]], + }; + passengerWaypoints = this._createWaypoints( + passengerPath.points, + Role.PASSENGER, + ); + paths.push(driverPath, passengerPath); + } + } else if (roles.includes(Role.DRIVER)) { + const driverPath: Path = { + key: RouteKey.DRIVER, + points: this._points, + }; + driverWaypoints = this._createWaypoints(driverPath.points, Role.DRIVER); + paths.push(driverPath); + } else if (roles.includes(Role.PASSENGER)) { + const passengerPath: Path = { + key: RouteKey.PASSENGER, + points: [this._points[0], this._points[this._points.length - 1]], + }; + passengerWaypoints = this._createWaypoints( + passengerPath.points, + Role.PASSENGER, + ); + paths.push(passengerPath); + } + const routes = await georouter.route(paths, { + withDistance: false, + withPoints: true, + withTime: false, + }); + if (routes.some((route) => route.key == RouteKey.COMMON)) { + this.driverRoute = routes.find( + (route) => route.key == RouteKey.COMMON, + ).route; + this.passengerRoute = routes.find( + (route) => route.key == RouteKey.COMMON, + ).route; + this.driverRoute.setWaypoints(driverWaypoints); + this.passengerRoute.setWaypoints(passengerWaypoints); + } else { + if (routes.some((route) => route.key == RouteKey.DRIVER)) { + this.driverRoute = routes.find( + (route) => route.key == RouteKey.DRIVER, + ).route; + this.driverRoute.setWaypoints(driverWaypoints); + } + if (routes.some((route) => route.key == RouteKey.PASSENGER)) { + this.passengerRoute = routes.find( + (route) => route.key == RouteKey.PASSENGER, + ).route; + this.passengerRoute.setWaypoints(passengerWaypoints); + } + } + }; + + _validateWaypoints = (): void => { + if (this._geographyRequest.waypoints.length < 2) { + throw new MatcherException(3, 'At least 2 waypoints are required'); + } + this._geographyRequest.waypoints.map((point) => { + if (!this._isValidPoint(point)) { + throw new MatcherException( + 3, + `Waypoint { Lon: ${point.lon}, Lat: ${point.lat} } is not valid`, + ); + } + this._points.push(point); + }); + }; + + _setTimezones = (): void => { + this.timezones = find( + this._geographyRequest.waypoints[0].lat, + this._geographyRequest.waypoints[0].lon, + ); + }; + + _setPointTypes = (): void => { + this.originType = + this._geographyRequest.waypoints[0].type ?? PointType.OTHER; + this.destinationType = + this._geographyRequest.waypoints[ + this._geographyRequest.waypoints.length - 1 + ].type ?? PointType.OTHER; + }; + + _isValidPoint = (point: Point): boolean => + this._isValidLongitude(point.lon) && this._isValidLatitude(point.lat); + + _isValidLongitude = (longitude: number): boolean => + longitude >= -180 && longitude <= 180; + + _isValidLatitude = (latitude: number): boolean => + latitude >= -90 && latitude <= 90; + + _createWaypoints = (points: Array, role: Role): Array => { + return points.map((point, index) => { + const waypoint = new Waypoint(point); + if (index == 0) { + waypoint.addActor(new Actor(this._person, role, Step.START)); + } else if (index == points.length - 1) { + waypoint.addActor(new Actor(this._person, role, Step.FINISH)); + } else { + waypoint.addActor(new Actor(this._person, role, Step.INTERMEDIATE)); + } + return waypoint; + }); + }; +} + +export enum RouteKey { + COMMON = 'common', + DRIVER = 'driver', + PASSENGER = 'passenger', +} diff --git a/src/modules/matcher/domain/entities/ecosystem/match.ts b/src/modules/matcher/domain/entities/ecosystem/match.ts new file mode 100644 index 0000000..83c399c --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/match.ts @@ -0,0 +1,6 @@ +import { AutoMap } from '@automapper/classes'; + +export class Match { + @AutoMap() + uuid: string; +} diff --git a/src/modules/matcher/domain/entities/ecosystem/named-route.ts b/src/modules/matcher/domain/entities/ecosystem/named-route.ts new file mode 100644 index 0000000..c57f928 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/named-route.ts @@ -0,0 +1,6 @@ +import { Route } from './route'; + +export type NamedRoute = { + key: string; + route: Route; +}; diff --git a/src/modules/matcher/domain/entities/ecosystem/person.ts b/src/modules/matcher/domain/entities/ecosystem/person.ts new file mode 100644 index 0000000..7340d07 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/person.ts @@ -0,0 +1,42 @@ +import { IRequestPerson } from '../../interfaces/person-request.interface'; + +export class Person { + _personRequest: IRequestPerson; + _defaultIdentifier: number; + _defaultMarginDuration: number; + identifier: number; + marginDurations: Array; + + constructor( + personRequest: IRequestPerson, + defaultIdentifier: number, + defaultMarginDuration: number, + ) { + this._personRequest = personRequest; + this._defaultIdentifier = defaultIdentifier; + this._defaultMarginDuration = defaultMarginDuration; + } + + init = (): void => { + this.setIdentifier( + this._personRequest.identifier ?? this._defaultIdentifier, + ); + this.setMarginDurations([ + this._defaultMarginDuration, + this._defaultMarginDuration, + this._defaultMarginDuration, + this._defaultMarginDuration, + this._defaultMarginDuration, + this._defaultMarginDuration, + this._defaultMarginDuration, + ]); + }; + + setIdentifier = (identifier: number): void => { + this.identifier = identifier; + }; + + setMarginDurations = (marginDurations: Array): void => { + this.marginDurations = marginDurations; + }; +} diff --git a/src/modules/matcher/domain/entities/ecosystem/requirement.ts b/src/modules/matcher/domain/entities/ecosystem/requirement.ts new file mode 100644 index 0000000..40db4c6 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/requirement.ts @@ -0,0 +1,13 @@ +import { IRequestRequirement } from '../../interfaces/requirement-request.interface'; + +export class Requirement { + _requirementRequest: IRequestRequirement; + seatsDriver: number; + seatsPassenger: number; + + constructor(requirementRequest: IRequestRequirement, defaultSeats: number) { + this._requirementRequest = requirementRequest; + this.seatsDriver = requirementRequest.seatsDriver ?? defaultSeats; + this.seatsPassenger = requirementRequest.seatsPassenger ?? 1; + } +} diff --git a/src/modules/matcher/domain/entities/ecosystem/route.ts b/src/modules/matcher/domain/entities/ecosystem/route.ts new file mode 100644 index 0000000..d468187 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/route.ts @@ -0,0 +1,56 @@ +import { IGeodesic } from '../../interfaces/geodesic.interface'; +import { Point } from '../../types/point.type'; +import { SpacetimePoint } from './spacetime-point'; +import { Waypoint } from './waypoint'; + +export class Route { + distance: number; + duration: number; + fwdAzimuth: number; + backAzimuth: number; + distanceAzimuth: number; + waypoints: Array; + points: Array; + spacetimePoints: Array; + _geodesic: IGeodesic; + + constructor(geodesic: IGeodesic) { + this.distance = undefined; + this.duration = undefined; + this.fwdAzimuth = undefined; + this.backAzimuth = undefined; + this.distanceAzimuth = undefined; + this.waypoints = []; + this.points = []; + this.spacetimePoints = []; + this._geodesic = geodesic; + } + + setWaypoints = (waypoints: Array): void => { + this.waypoints = waypoints; + this._setAzimuth(waypoints.map((waypoint) => waypoint.point)); + }; + + setPoints = (points: Array): void => { + this.points = points; + this._setAzimuth(points); + }; + + setSpacetimePoints = (spacetimePoints: Array): void => { + this.spacetimePoints = spacetimePoints; + }; + + _setAzimuth = (points: Array): void => { + const inverse = this._geodesic.inverse( + points[0].lon, + points[0].lat, + points[points.length - 1].lon, + points[points.length - 1].lat, + ); + this.fwdAzimuth = + inverse.azimuth >= 0 ? inverse.azimuth : 360 - Math.abs(inverse.azimuth); + this.backAzimuth = + this.fwdAzimuth > 180 ? this.fwdAzimuth - 180 : this.fwdAzimuth + 180; + this.distanceAzimuth = inverse.distance; + }; +} diff --git a/src/modules/matcher/domain/entities/ecosystem/spacetime-point.ts b/src/modules/matcher/domain/entities/ecosystem/spacetime-point.ts new file mode 100644 index 0000000..98fe80f --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/spacetime-point.ts @@ -0,0 +1,11 @@ +export class SpacetimePoint { + point: Array; + duration: number; + distance: number; + + constructor(point: Array, duration: number, distance: number) { + this.point = point; + this.duration = duration; + this.distance = distance; + } +} diff --git a/src/modules/matcher/domain/entities/ecosystem/time.ts b/src/modules/matcher/domain/entities/ecosystem/time.ts new file mode 100644 index 0000000..c4a39c5 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/time.ts @@ -0,0 +1,169 @@ +import { MatcherException } from '../../../exceptions/matcher.exception'; +import { MarginDurations } from '../../types/margin-durations.type'; +import { IRequestTime } from '../../interfaces/time-request.interface'; +import { TimingDays, TimingFrequency, Days } from '../../types/timing'; +import { Schedule } from '../../types/schedule.type'; + +export class Time { + _timeRequest: IRequestTime; + _defaultMarginDuration: number; + _defaultValidityDuration: number; + frequency: TimingFrequency; + fromDate: Date; + toDate: Date; + schedule: Schedule; + marginDurations: MarginDurations; + + constructor( + timeRequest: IRequestTime, + defaultMarginDuration: number, + defaultValidityDuration: number, + ) { + this._timeRequest = timeRequest; + this._defaultMarginDuration = defaultMarginDuration; + this._defaultValidityDuration = defaultValidityDuration; + this.schedule = {}; + this.marginDurations = { + mon: defaultMarginDuration, + tue: defaultMarginDuration, + wed: defaultMarginDuration, + thu: defaultMarginDuration, + fri: defaultMarginDuration, + sat: defaultMarginDuration, + sun: defaultMarginDuration, + }; + } + + init = (): void => { + this._validateBaseDate(); + this._validatePunctualRequest(); + this._validateRecurrentRequest(); + this._setPunctualRequest(); + this._setRecurrentRequest(); + this._setMargindurations(); + }; + + _validateBaseDate = (): void => { + if (!this._timeRequest.departure && !this._timeRequest.fromDate) { + throw new MatcherException(3, 'departure or fromDate is required'); + } + }; + + _validatePunctualRequest = (): void => { + if (this._timeRequest.departure) { + this.fromDate = this.toDate = new Date(this._timeRequest.departure); + if (!this._isDate(this.fromDate)) { + throw new MatcherException(3, 'Wrong departure date'); + } + } + }; + + _validateRecurrentRequest = (): void => { + if (this._timeRequest.fromDate) { + this.fromDate = new Date(this._timeRequest.fromDate); + if (!this._isDate(this.fromDate)) { + throw new MatcherException(3, 'Wrong fromDate'); + } + } + if (this._timeRequest.toDate) { + this.toDate = new Date(this._timeRequest.toDate); + if (!this._isDate(this.toDate)) { + throw new MatcherException(3, 'Wrong toDate'); + } + if (this.toDate < this.fromDate) { + throw new MatcherException(3, 'toDate must be after fromDate'); + } + } + if (this._timeRequest.fromDate) { + this._validateSchedule(); + } + }; + + _validateSchedule = (): void => { + if (!this._timeRequest.schedule) { + throw new MatcherException(3, 'Schedule is required'); + } + if ( + !Object.keys(this._timeRequest.schedule).some((elem) => + Days.includes(elem), + ) + ) { + throw new MatcherException(3, 'No valid day in the given schedule'); + } + Object.keys(this._timeRequest.schedule).map((day) => { + const time = new Date('1970-01-01 ' + this._timeRequest.schedule[day]); + if (!this._isDate(time)) { + throw new MatcherException(3, `Wrong time for ${day} in schedule`); + } + }); + }; + + _setPunctualRequest = (): void => { + if (this._timeRequest.departure) { + this.frequency = TimingFrequency.FREQUENCY_PUNCTUAL; + this.schedule[TimingDays[this.fromDate.getDay()]] = + this.fromDate.getHours() + ':' + this.fromDate.getMinutes(); + } + }; + + _setRecurrentRequest = (): void => { + if (this._timeRequest.fromDate) { + this.frequency = TimingFrequency.FREQUENCY_RECURRENT; + if (!this.toDate) { + this.toDate = this._addDays( + this.fromDate, + this._defaultValidityDuration, + ); + } + this._setSchedule(); + } + }; + + _setSchedule = (): void => { + Object.keys(this._timeRequest.schedule).map((day) => { + this.schedule[day] = this._timeRequest.schedule[day]; + }); + }; + + _setMargindurations = (): void => { + if (this._timeRequest.marginDuration) { + const duration = Math.abs(this._timeRequest.marginDuration); + this.marginDurations = { + mon: duration, + tue: duration, + wed: duration, + thu: duration, + fri: duration, + sat: duration, + sun: duration, + }; + } + if (this._timeRequest.marginDurations) { + if ( + !Object.keys(this._timeRequest.marginDurations).some((elem) => + Days.includes(elem), + ) + ) { + throw new MatcherException( + 3, + 'No valid day in the given margin durations', + ); + } + Object.keys(this._timeRequest.marginDurations).map((day) => { + this.marginDurations[day] = Math.abs( + this._timeRequest.marginDurations[day], + ); + }); + } + }; + + _isDate = (date: Date): boolean => { + return date instanceof Date && isFinite(+date); + }; + + _addDays = (date: Date, days: number): Date => { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + }; +} diff --git a/src/modules/matcher/domain/entities/ecosystem/waypoint.ts b/src/modules/matcher/domain/entities/ecosystem/waypoint.ts new file mode 100644 index 0000000..fdcbea0 --- /dev/null +++ b/src/modules/matcher/domain/entities/ecosystem/waypoint.ts @@ -0,0 +1,14 @@ +import { Point } from '../../types/point.type'; +import { Actor } from './actor'; + +export class Waypoint { + point: Point; + actors: Array; + + constructor(point: Point) { + this.point = point; + this.actors = []; + } + + addActor = (actor: Actor) => this.actors.push(actor); +} diff --git a/src/modules/matcher/domain/entities/engine/candidate.ts b/src/modules/matcher/domain/entities/engine/candidate.ts new file mode 100644 index 0000000..1a19a59 --- /dev/null +++ b/src/modules/matcher/domain/entities/engine/candidate.ts @@ -0,0 +1,5 @@ +import { Person } from '../ecosystem/person'; + +export class Candidate { + person: Person; +} diff --git a/src/modules/matcher/domain/entities/engine/factory/algorithm-factory.abstract.ts b/src/modules/matcher/domain/entities/engine/factory/algorithm-factory.abstract.ts new file mode 100644 index 0000000..67206e8 --- /dev/null +++ b/src/modules/matcher/domain/entities/engine/factory/algorithm-factory.abstract.ts @@ -0,0 +1,15 @@ +import { MatchQuery } from 'src/modules/matcher/queries/match.query'; +import { Processor } from '../processor.abstract'; +import { Candidate } from '../candidate'; + +export abstract class AlgorithmFactory { + _matchQuery: MatchQuery; + _candidates: Array; + + constructor(matchQuery: MatchQuery) { + this._matchQuery = matchQuery; + this._candidates = []; + } + + abstract createProcessors(): Array; +} diff --git a/src/modules/matcher/domain/entities/engine/factory/classic.ts b/src/modules/matcher/domain/entities/engine/factory/classic.ts new file mode 100644 index 0000000..77a2d04 --- /dev/null +++ b/src/modules/matcher/domain/entities/engine/factory/classic.ts @@ -0,0 +1,9 @@ +import { AlgorithmFactory } from './algorithm-factory.abstract'; +import { Processor } from '../processor.abstract'; +import { ClassicWaypointsCompleter } from '../processor/completer/classic-waypoint.completer.processor'; + +export class ClassicAlgorithmFactory extends AlgorithmFactory { + createProcessors(): Array { + return [new ClassicWaypointsCompleter(this._matchQuery)]; + } +} diff --git a/src/modules/matcher/domain/entities/engine/matcher.ts b/src/modules/matcher/domain/entities/engine/matcher.ts new file mode 100644 index 0000000..bc6da9c --- /dev/null +++ b/src/modules/matcher/domain/entities/engine/matcher.ts @@ -0,0 +1,21 @@ +import { MatchQuery } from '../../../queries/match.query'; +import { Algorithm } from '../../types/algorithm.enum'; +import { Match } from '../ecosystem/match'; +import { Candidate } from './candidate'; +import { AlgorithmFactory } from './factory/algorithm-factory.abstract'; +import { ClassicAlgorithmFactory } from './factory/classic'; + +export class Matcher { + match = (matchQuery: MatchQuery): Array => { + let algorithm: AlgorithmFactory; + switch (matchQuery.algorithmSettings.algorithm) { + case Algorithm.CLASSIC: + algorithm = new ClassicAlgorithmFactory(matchQuery); + } + let candidates: Array = []; + for (const processor of algorithm.createProcessors()) { + candidates = processor.execute(candidates); + } + return []; + }; +} diff --git a/src/modules/matcher/domain/entities/engine/processor.abstract.ts b/src/modules/matcher/domain/entities/engine/processor.abstract.ts new file mode 100644 index 0000000..c5df1a6 --- /dev/null +++ b/src/modules/matcher/domain/entities/engine/processor.abstract.ts @@ -0,0 +1,12 @@ +import { MatchQuery } from 'src/modules/matcher/queries/match.query'; +import { Candidate } from './candidate'; + +export abstract class Processor { + _matchQuery: MatchQuery; + + constructor(matchQuery: MatchQuery) { + this._matchQuery = matchQuery; + } + + abstract execute(candidates: Array): Array; +} diff --git a/src/modules/matcher/domain/entities/engine/processor/completer/classic-waypoint.completer.processor.ts b/src/modules/matcher/domain/entities/engine/processor/completer/classic-waypoint.completer.processor.ts new file mode 100644 index 0000000..b55522a --- /dev/null +++ b/src/modules/matcher/domain/entities/engine/processor/completer/classic-waypoint.completer.processor.ts @@ -0,0 +1,8 @@ +import { Candidate } from '../../candidate'; +import { Completer } from './completer.abstract'; + +export class ClassicWaypointsCompleter extends Completer { + complete(candidates: Array): Array { + return []; + } +} diff --git a/src/modules/matcher/domain/entities/engine/processor/completer/completer.abstract.ts b/src/modules/matcher/domain/entities/engine/processor/completer/completer.abstract.ts new file mode 100644 index 0000000..29f408d --- /dev/null +++ b/src/modules/matcher/domain/entities/engine/processor/completer/completer.abstract.ts @@ -0,0 +1,9 @@ +import { Candidate } from '../../candidate'; +import { Processor } from '../../processor.abstract'; + +export abstract class Completer extends Processor { + execute = (candidates: Array): Array => + this.complete(candidates); + + abstract complete(candidates: Array): Array; +} diff --git a/src/modules/matcher/domain/interfaces/algorithm-settings-request.interface.ts b/src/modules/matcher/domain/interfaces/algorithm-settings-request.interface.ts new file mode 100644 index 0000000..3ab0de8 --- /dev/null +++ b/src/modules/matcher/domain/interfaces/algorithm-settings-request.interface.ts @@ -0,0 +1,13 @@ +import { Algorithm } from '../types/algorithm.enum'; + +export interface IRequestAlgorithmSettings { + algorithm: Algorithm; + strict: boolean; + remoteness: number; + useProportion: boolean; + proportion: number; + useAzimuth: boolean; + azimuthMargin: number; + maxDetourDistanceRatio: number; + maxDetourDurationRatio: number; +} diff --git a/src/modules/matcher/domain/interfaces/geodesic.interface.ts b/src/modules/matcher/domain/interfaces/geodesic.interface.ts new file mode 100644 index 0000000..95680e8 --- /dev/null +++ b/src/modules/matcher/domain/interfaces/geodesic.interface.ts @@ -0,0 +1,11 @@ +export interface IGeodesic { + inverse( + lon1: number, + lat1: number, + lon2: number, + lat2: number, + ): { + azimuth: number; + distance: number; + }; +} diff --git a/src/modules/matcher/domain/interfaces/geography-request.interface.ts b/src/modules/matcher/domain/interfaces/geography-request.interface.ts new file mode 100644 index 0000000..d10a6ac --- /dev/null +++ b/src/modules/matcher/domain/interfaces/geography-request.interface.ts @@ -0,0 +1,5 @@ +import { Point } from '../types/point.type'; + +export interface IRequestGeography { + waypoints: Array; +} diff --git a/src/modules/matcher/domain/interfaces/georouter-creator.interface.ts b/src/modules/matcher/domain/interfaces/georouter-creator.interface.ts new file mode 100644 index 0000000..7a6bd25 --- /dev/null +++ b/src/modules/matcher/domain/interfaces/georouter-creator.interface.ts @@ -0,0 +1,5 @@ +import { IGeorouter } from './georouter.interface'; + +export interface ICreateGeorouter { + create(type: string, url: string): IGeorouter; +} diff --git a/src/modules/matcher/domain/interfaces/georouter.interface.ts b/src/modules/matcher/domain/interfaces/georouter.interface.ts new file mode 100644 index 0000000..5f09b23 --- /dev/null +++ b/src/modules/matcher/domain/interfaces/georouter.interface.ts @@ -0,0 +1,10 @@ +import { NamedRoute } from '../entities/ecosystem/named-route'; +import { GeorouterSettings } from '../types/georouter-settings.type'; +import { Path } from '../types/path.type'; + +export interface IGeorouter { + route( + paths: Array, + settings: GeorouterSettings, + ): Promise>; +} diff --git a/src/modules/matcher/domain/interfaces/person-request.interface.ts b/src/modules/matcher/domain/interfaces/person-request.interface.ts new file mode 100644 index 0000000..9dd8075 --- /dev/null +++ b/src/modules/matcher/domain/interfaces/person-request.interface.ts @@ -0,0 +1,3 @@ +export interface IRequestPerson { + identifier?: number; +} diff --git a/src/modules/matcher/domain/interfaces/requirement-request.interface.ts b/src/modules/matcher/domain/interfaces/requirement-request.interface.ts new file mode 100644 index 0000000..61e5900 --- /dev/null +++ b/src/modules/matcher/domain/interfaces/requirement-request.interface.ts @@ -0,0 +1,4 @@ +export interface IRequestRequirement { + seatsDriver?: number; + seatsPassenger?: number; +} diff --git a/src/modules/matcher/domain/interfaces/time-request.interface.ts b/src/modules/matcher/domain/interfaces/time-request.interface.ts new file mode 100644 index 0000000..1f8c6a7 --- /dev/null +++ b/src/modules/matcher/domain/interfaces/time-request.interface.ts @@ -0,0 +1,11 @@ +import { MarginDurations } from '../types/margin-durations.type'; +import { Schedule } from '../types/schedule.type'; + +export interface IRequestTime { + departure?: string; + fromDate?: string; + toDate?: string; + schedule?: Schedule; + marginDuration?: number; + marginDurations?: MarginDurations; +} diff --git a/src/modules/matcher/domain/types/actor.type..ts b/src/modules/matcher/domain/types/actor.type..ts new file mode 100644 index 0000000..aecaa9e --- /dev/null +++ b/src/modules/matcher/domain/types/actor.type..ts @@ -0,0 +1,9 @@ +import { Person } from '../entities/ecosystem/person'; +import { Role } from './role.enum'; +import { Step } from './step.enum'; + +export type Actor = { + person: Person; + role: Role; + step: Step; +}; diff --git a/src/modules/matcher/domain/types/algorithm.enum.ts b/src/modules/matcher/domain/types/algorithm.enum.ts new file mode 100644 index 0000000..0ed0cbc --- /dev/null +++ b/src/modules/matcher/domain/types/algorithm.enum.ts @@ -0,0 +1,3 @@ +export enum Algorithm { + CLASSIC = 'CLASSIC', +} diff --git a/src/modules/matcher/domain/types/default-algorithm-settings.type.ts b/src/modules/matcher/domain/types/default-algorithm-settings.type.ts new file mode 100644 index 0000000..89c0c93 --- /dev/null +++ b/src/modules/matcher/domain/types/default-algorithm-settings.type.ts @@ -0,0 +1,15 @@ +import { Algorithm } from './algorithm.enum'; + +export type DefaultAlgorithmSettings = { + algorithm: Algorithm; + strict: boolean; + remoteness: number; + useProportion: boolean; + proportion: number; + useAzimuth: boolean; + azimuthMargin: number; + maxDetourDistanceRatio: number; + maxDetourDurationRatio: number; + georouterType: string; + georouterUrl: string; +}; diff --git a/src/modules/matcher/domain/types/default-params.type.ts b/src/modules/matcher/domain/types/default-params.type.ts new file mode 100644 index 0000000..f39bd3b --- /dev/null +++ b/src/modules/matcher/domain/types/default-params.type.ts @@ -0,0 +1,10 @@ +import { DefaultAlgorithmSettings } from './default-algorithm-settings.type'; + +export type IDefaultParams = { + DEFAULT_IDENTIFIER: number; + MARGIN_DURATION: number; + VALIDITY_DURATION: number; + DEFAULT_TIMEZONE: string; + DEFAULT_SEATS: number; + DEFAULT_ALGORITHM_SETTINGS: DefaultAlgorithmSettings; +}; diff --git a/src/modules/matcher/domain/types/geography.enum.ts b/src/modules/matcher/domain/types/geography.enum.ts new file mode 100644 index 0000000..e1e57a2 --- /dev/null +++ b/src/modules/matcher/domain/types/geography.enum.ts @@ -0,0 +1,7 @@ +export enum PointType { + HOUSE_NUMBER = 'HOUSE_NUMBER', + STREET_ADDRESS = 'STREET_ADDRESS', + LOCALITY = 'LOCALITY', + VENUE = 'VENUE', + OTHER = 'OTHER', +} diff --git a/src/modules/matcher/domain/types/georouter-settings.type.ts b/src/modules/matcher/domain/types/georouter-settings.type.ts new file mode 100644 index 0000000..d8f73ae --- /dev/null +++ b/src/modules/matcher/domain/types/georouter-settings.type.ts @@ -0,0 +1,5 @@ +export type GeorouterSettings = { + withPoints: boolean; + withTime: boolean; + withDistance: boolean; +}; diff --git a/src/modules/matcher/domain/types/margin-durations.type.ts b/src/modules/matcher/domain/types/margin-durations.type.ts new file mode 100644 index 0000000..8e09329 --- /dev/null +++ b/src/modules/matcher/domain/types/margin-durations.type.ts @@ -0,0 +1,9 @@ +export type MarginDurations = { + mon?: number; + tue?: number; + wed?: number; + thu?: number; + fri?: number; + sat?: number; + sun?: number; +}; diff --git a/src/modules/matcher/domain/types/path.type.ts b/src/modules/matcher/domain/types/path.type.ts new file mode 100644 index 0000000..8a1bfe9 --- /dev/null +++ b/src/modules/matcher/domain/types/path.type.ts @@ -0,0 +1,6 @@ +import { Point } from './point.type'; + +export type Path = { + key: string; + points: Array; +}; diff --git a/src/modules/matcher/domain/types/point.type.ts b/src/modules/matcher/domain/types/point.type.ts new file mode 100644 index 0000000..8d32fe0 --- /dev/null +++ b/src/modules/matcher/domain/types/point.type.ts @@ -0,0 +1,7 @@ +import { PointType } from './geography.enum'; + +export type Point = { + lon: number; + lat: number; + type?: PointType; +}; diff --git a/src/modules/matcher/domain/types/role.enum.ts b/src/modules/matcher/domain/types/role.enum.ts new file mode 100644 index 0000000..7522f80 --- /dev/null +++ b/src/modules/matcher/domain/types/role.enum.ts @@ -0,0 +1,4 @@ +export enum Role { + DRIVER = 'DRIVER', + PASSENGER = 'PASSENGER', +} diff --git a/src/modules/matcher/domain/types/schedule.type.ts b/src/modules/matcher/domain/types/schedule.type.ts new file mode 100644 index 0000000..03f8485 --- /dev/null +++ b/src/modules/matcher/domain/types/schedule.type.ts @@ -0,0 +1,9 @@ +export type Schedule = { + mon?: string; + tue?: string; + wed?: string; + thu?: string; + fri?: string; + sat?: string; + sun?: string; +}; diff --git a/src/modules/matcher/domain/types/step.enum.ts b/src/modules/matcher/domain/types/step.enum.ts new file mode 100644 index 0000000..4b2fce4 --- /dev/null +++ b/src/modules/matcher/domain/types/step.enum.ts @@ -0,0 +1,6 @@ +export enum Step { + START = 'start', + INTERMEDIATE = 'intermediate', + NEUTRAL = 'neutral', + FINISH = 'finish', +} diff --git a/src/modules/matcher/domain/types/timing.ts b/src/modules/matcher/domain/types/timing.ts new file mode 100644 index 0000000..567595a --- /dev/null +++ b/src/modules/matcher/domain/types/timing.ts @@ -0,0 +1,16 @@ +export enum TimingFrequency { + FREQUENCY_PUNCTUAL = 1, + FREQUENCY_RECURRENT = 2, +} + +export enum TimingDays { + 'sun', + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', +} + +export const Days = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; diff --git a/src/modules/matcher/domain/types/waypoint.ts b/src/modules/matcher/domain/types/waypoint.ts new file mode 100644 index 0000000..6ee5941 --- /dev/null +++ b/src/modules/matcher/domain/types/waypoint.ts @@ -0,0 +1,7 @@ +import { Actor } from './actor.type.'; +import { Point } from './point.type'; + +export type Waypoint = { + point: Point; + actors: Array; +}; diff --git a/src/modules/matcher/domain/usecases/match.usecase.ts b/src/modules/matcher/domain/usecases/match.usecase.ts new file mode 100644 index 0000000..44ce17b --- /dev/null +++ b/src/modules/matcher/domain/usecases/match.usecase.ts @@ -0,0 +1,77 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { QueryHandler } from '@nestjs/cqrs'; +import { Messager } from '../../adapters/secondaries/messager'; +import { MatchQuery } from '../../queries/match.query'; +import { AdRepository } from '../../adapters/secondaries/ad.repository'; +import { Match } from '../entities/ecosystem/match'; +import { ICollection } from '../../../database/src/interfaces/collection.interface'; + +@QueryHandler(MatchQuery) +export class MatchUseCase { + constructor( + private readonly _repository: AdRepository, + private readonly _messager: Messager, + @InjectMapper() private readonly _mapper: Mapper, + ) {} + + execute = async (matchQuery: MatchQuery): Promise> => { + try { + // const paths = []; + // for (let i = 0; i < 1; i++) { + // paths.push({ + // key: 'route' + i, + // points: [ + // { + // lat: 48.110899, + // lon: -1.68365, + // }, + // { + // lat: 48.131105, + // lon: -1.690067, + // }, + // { + // lat: 48.534769, + // lon: -1.894032, + // }, + // { + // lat: 48.56516, + // lon: -1.923553, + // }, + // { + // lat: 48.622813, + // lon: -1.997177, + // }, + // { + // lat: 48.67846, + // lon: -1.8554, + // }, + // ], + // }); + // } + // const routes = await matchQuery.algorithmSettings.georouter.route(paths, { + // withDistance: false, + // withPoints: true, + // withTime: true, + // }); + // routes.map((route) => console.log(route.route.spacetimePoints)); + const match = new Match(); + match.uuid = 'e23f9725-2c19-49a0-9ef6-17d8b9a5ec85'; + this._messager.publish('matcher.match', 'match !'); + return { + data: [match], + total: 1, + }; + } catch (error) { + const err: Error = error; + this._messager.publish( + 'logging.matcher.match.crit', + JSON.stringify({ + matchQuery, + error: err.message, + }), + ); + throw error; + } + }; +} diff --git a/src/modules/matcher/exceptions/matcher.exception.ts b/src/modules/matcher/exceptions/matcher.exception.ts new file mode 100644 index 0000000..c72c694 --- /dev/null +++ b/src/modules/matcher/exceptions/matcher.exception.ts @@ -0,0 +1,13 @@ +export class MatcherException implements Error { + name: string; + message: string; + + constructor(private _code: number, private _message: string) { + this.name = 'MatcherException'; + this.message = _message; + } + + get code(): number { + return this._code; + } +} diff --git a/src/modules/matcher/mappers/match.profile.ts b/src/modules/matcher/mappers/match.profile.ts new file mode 100644 index 0000000..c44fef8 --- /dev/null +++ b/src/modules/matcher/mappers/match.profile.ts @@ -0,0 +1,18 @@ +import { createMap, Mapper } from '@automapper/core'; +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { MatchPresenter } from '../adapters/secondaries/match.presenter'; +import { Match } from '../domain/entities/ecosystem/match'; + +@Injectable() +export class MatchProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap(mapper, Match, MatchPresenter); + }; + } +} diff --git a/src/modules/matcher/matcher.module.ts b/src/modules/matcher/matcher.module.ts new file mode 100644 index 0000000..7173746 --- /dev/null +++ b/src/modules/matcher/matcher.module.ts @@ -0,0 +1,63 @@ +import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { CqrsModule } from '@nestjs/cqrs'; +import { DatabaseModule } from '../database/database.module'; +import { MatcherController } from './adapters/primaries/matcher.controller'; +import { MatchProfile } from './mappers/match.profile'; +import { AdRepository } from './adapters/secondaries/ad.repository'; +import { MatchUseCase } from './domain/usecases/match.usecase'; +import { Messager } from './adapters/secondaries/messager'; +import { CacheModule } from '@nestjs/cache-manager'; +import { RedisClientOptions } from '@liaoliaots/nestjs-redis'; +import { redisStore } from 'cache-manager-ioredis-yet'; +import { DefaultParamsProvider } from './adapters/secondaries/default-params.provider'; +import { GeorouterCreator } from './adapters/secondaries/georouter-creator'; +import { HttpModule } from '@nestjs/axios'; +import { MatcherGeodesic } from './adapters/secondaries/geodesic'; + +@Module({ + imports: [ + DatabaseModule, + CqrsModule, + HttpModule, + RabbitMQModule.forRootAsync(RabbitMQModule, { + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + exchanges: [ + { + name: configService.get('RMQ_EXCHANGE'), + type: 'topic', + }, + ], + uri: configService.get('RMQ_URI'), + connectionInitOptions: { wait: false }, + }), + inject: [ConfigService], + }), + CacheModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + store: await redisStore({ + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT'), + password: configService.get('REDIS_PASSWORD'), + ttl: configService.get('CACHE_TTL'), + }), + }), + inject: [ConfigService], + }), + ], + controllers: [MatcherController], + providers: [ + MatchProfile, + AdRepository, + Messager, + DefaultParamsProvider, + MatchUseCase, + GeorouterCreator, + MatcherGeodesic, + ], + exports: [], +}) +export class MatcherModule {} diff --git a/src/modules/matcher/queries/match.query.ts b/src/modules/matcher/queries/match.query.ts new file mode 100644 index 0000000..b5c62fc --- /dev/null +++ b/src/modules/matcher/queries/match.query.ts @@ -0,0 +1,103 @@ +import { MatchRequest } from '../domain/dtos/match.request'; +import { Geography } from '../domain/entities/ecosystem/geography'; +import { Person } from '../domain/entities/ecosystem/person'; +import { Requirement } from '../domain/entities/ecosystem/requirement'; +import { Role } from '../domain/types/role.enum'; +import { AlgorithmSettings } from '../domain/entities/ecosystem/algorithm-settings'; +import { Time } from '../domain/entities/ecosystem/time'; +import { IDefaultParams } from '../domain/types/default-params.type'; +import { IGeorouter } from '../domain/interfaces/georouter.interface'; +import { ICreateGeorouter } from '../domain/interfaces/georouter-creator.interface'; + +export class MatchQuery { + private readonly _matchRequest: MatchRequest; + private readonly _defaultParams: IDefaultParams; + private readonly _georouterCreator: ICreateGeorouter; + person: Person; + roles: Array; + time: Time; + geography: Geography; + exclusions: Array; + requirement: Requirement; + algorithmSettings: AlgorithmSettings; + georouter: IGeorouter; + + constructor( + matchRequest: MatchRequest, + defaultParams: IDefaultParams, + georouterCreator: ICreateGeorouter, + ) { + this._matchRequest = matchRequest; + this._defaultParams = defaultParams; + this._georouterCreator = georouterCreator; + this._setPerson(); + this._setRoles(); + this._setTime(); + this._setGeography(); + this._setRequirement(); + this._setAlgorithmSettings(); + this._setExclusions(); + } + + createRoutes = (): void => { + this.geography.createRoutes(this.roles, this.algorithmSettings.georouter); + }; + + _setPerson = (): void => { + this.person = new Person( + this._matchRequest, + this._defaultParams.DEFAULT_IDENTIFIER, + this._defaultParams.MARGIN_DURATION, + ); + this.person.init(); + }; + + _setRoles = (): void => { + this.roles = []; + if (this._matchRequest.driver) this.roles.push(Role.DRIVER); + if (this._matchRequest.passenger) this.roles.push(Role.PASSENGER); + if (this.roles.length == 0) this.roles.push(Role.PASSENGER); + }; + + _setTime = (): void => { + this.time = new Time( + this._matchRequest, + this._defaultParams.MARGIN_DURATION, + this._defaultParams.VALIDITY_DURATION, + ); + this.time.init(); + }; + + _setGeography = (): void => { + this.geography = new Geography( + this._matchRequest, + this._defaultParams.DEFAULT_TIMEZONE, + this.person, + ); + this.geography.init(); + }; + + _setRequirement = (): void => { + this.requirement = new Requirement( + this._matchRequest, + this._defaultParams.DEFAULT_SEATS, + ); + }; + + _setAlgorithmSettings = (): void => { + this.algorithmSettings = new AlgorithmSettings( + this._matchRequest, + this._defaultParams.DEFAULT_ALGORITHM_SETTINGS, + this.time.frequency, + this._georouterCreator, + ); + }; + + _setExclusions = (): void => { + this.exclusions = []; + if (this._matchRequest.identifier) + this.exclusions.push(this._matchRequest.identifier); + if (this._matchRequest.exclusions) + this.exclusions.push(...this._matchRequest.exclusions); + }; +} diff --git a/src/modules/matcher/tests/unit/adapters/secondaries/default-params.provider.spec.ts b/src/modules/matcher/tests/unit/adapters/secondaries/default-params.provider.spec.ts new file mode 100644 index 0000000..5221c14 --- /dev/null +++ b/src/modules/matcher/tests/unit/adapters/secondaries/default-params.provider.spec.ts @@ -0,0 +1,38 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DefaultParamsProvider } from '../../../../adapters/secondaries/default-params.provider'; +import { IDefaultParams } from '../../../../domain/types/default-params.type'; + +const mockConfigService = { + get: jest.fn().mockImplementationOnce(() => 99), +}; + +describe('DefaultParamsProvider', () => { + let defaultParamsProvider: DefaultParamsProvider; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + DefaultParamsProvider, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + defaultParamsProvider = module.get( + DefaultParamsProvider, + ); + }); + + it('should be defined', () => { + expect(defaultParamsProvider).toBeDefined(); + }); + + it('should provide default params', async () => { + const params: IDefaultParams = defaultParamsProvider.getParams(); + expect(params.DEFAULT_IDENTIFIER).toBe(99); + }); +}); diff --git a/src/modules/matcher/tests/unit/adapters/secondaries/geodesic.spec.ts b/src/modules/matcher/tests/unit/adapters/secondaries/geodesic.spec.ts new file mode 100644 index 0000000..9e08335 --- /dev/null +++ b/src/modules/matcher/tests/unit/adapters/secondaries/geodesic.spec.ts @@ -0,0 +1,14 @@ +import { MatcherGeodesic } from '../../../../adapters/secondaries/geodesic'; + +describe('Matcher geodesic', () => { + it('should be defined', () => { + const geodesic: MatcherGeodesic = new MatcherGeodesic(); + expect(geodesic).toBeDefined(); + }); + it('should get inverse values', () => { + const geodesic: MatcherGeodesic = new MatcherGeodesic(); + const inv = geodesic.inverse(0, 0, 1, 1); + expect(Math.round(inv.azimuth)).toBe(45); + expect(Math.round(inv.distance)).toBe(156900); + }); +}); diff --git a/src/modules/matcher/tests/unit/adapters/secondaries/georouter-creator.spec.ts b/src/modules/matcher/tests/unit/adapters/secondaries/georouter-creator.spec.ts new file mode 100644 index 0000000..543991b --- /dev/null +++ b/src/modules/matcher/tests/unit/adapters/secondaries/georouter-creator.spec.ts @@ -0,0 +1,47 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GeorouterCreator } from '../../../../adapters/secondaries/georouter-creator'; +import { GraphhopperGeorouter } from '../../../../adapters/secondaries/graphhopper-georouter'; +import { HttpService } from '@nestjs/axios'; +import { MatcherGeodesic } from '../../../../adapters/secondaries/geodesic'; + +const mockHttpService = jest.fn(); +const mockMatcherGeodesic = jest.fn(); + +describe('Georouter creator', () => { + let georouterCreator: GeorouterCreator; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + GeorouterCreator, + { + provide: HttpService, + useValue: mockHttpService, + }, + { + provide: MatcherGeodesic, + useValue: mockMatcherGeodesic, + }, + ], + }).compile(); + + georouterCreator = module.get(GeorouterCreator); + }); + + it('should be defined', () => { + expect(georouterCreator).toBeDefined(); + }); + it('should create a graphhopper georouter', () => { + const georouter = georouterCreator.create( + 'graphhopper', + 'http://localhost', + ); + expect(georouter).toBeInstanceOf(GraphhopperGeorouter); + }); + it('should throw an exception if georouter type is unknown', () => { + expect(() => + georouterCreator.create('unknown', 'http://localhost'), + ).toThrow(); + }); +}); diff --git a/src/modules/matcher/tests/unit/adapters/secondaries/graphhopper-georouter.spec.ts b/src/modules/matcher/tests/unit/adapters/secondaries/graphhopper-georouter.spec.ts new file mode 100644 index 0000000..fdfef4b --- /dev/null +++ b/src/modules/matcher/tests/unit/adapters/secondaries/graphhopper-georouter.spec.ts @@ -0,0 +1,456 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; +import { GeorouterCreator } from '../../../../adapters/secondaries/georouter-creator'; +import { IGeorouter } from '../../../../domain/interfaces/georouter.interface'; +import { of } from 'rxjs'; +import { AxiosError } from 'axios'; +import { MatcherGeodesic } from '../../../../adapters/secondaries/geodesic'; + +const mockHttpService = { + get: jest + .fn() + .mockImplementationOnce(() => { + throw new AxiosError('Axios error !'); + }) + .mockImplementationOnce(() => { + return of({ + status: 200, + data: { + paths: [ + { + distance: 50000, + time: 1800000, + snapped_waypoints: { + coordinates: [ + [0, 0], + [10, 10], + ], + }, + }, + ], + }, + }); + }) + .mockImplementationOnce(() => { + return of({ + status: 200, + data: { + paths: [ + { + distance: 50000, + time: 1800000, + points: { + coordinates: [ + [0, 0], + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [5, 5], + [6, 6], + [7, 7], + [8, 8], + [9, 9], + [10, 10], + ], + }, + snapped_waypoints: { + coordinates: [ + [0, 0], + [10, 10], + ], + }, + }, + ], + }, + }); + }) + .mockImplementationOnce(() => { + return of({ + status: 200, + data: { + paths: [ + { + distance: 50000, + time: 1800000, + points: { + coordinates: [ + [0, 0], + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [5, 5], + [6, 6], + [7, 7], + [8, 8], + [9, 9], + [10, 10], + ], + }, + details: { + time: [ + [0, 1, 180000], + [1, 2, 180000], + [2, 3, 180000], + [3, 4, 180000], + [4, 5, 180000], + [5, 6, 180000], + [6, 7, 180000], + [7, 9, 360000], + [9, 10, 180000], + ], + }, + snapped_waypoints: { + coordinates: [ + [0, 0], + [10, 10], + ], + }, + }, + ], + }, + }); + }) + .mockImplementationOnce(() => { + return of({ + status: 200, + data: { + paths: [ + { + distance: 50000, + time: 1800000, + points: { + coordinates: [ + [0, 0], + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [7, 7], + [8, 8], + [9, 9], + [10, 10], + ], + }, + snapped_waypoints: { + coordinates: [ + [0, 0], + [5, 5], + [10, 10], + ], + }, + details: { + time: [ + [0, 1, 180000], + [1, 2, 180000], + [2, 3, 180000], + [3, 4, 180000], + [4, 7, 540000], + [7, 9, 360000], + [9, 10, 180000], + ], + }, + }, + ], + }, + }); + }) + .mockImplementationOnce(() => { + return of({ + status: 200, + data: { + paths: [ + { + distance: 50000, + time: 1800000, + points: { + coordinates: [ + [0, 0], + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [5, 5], + [6, 6], + [7, 7], + [8, 8], + [9, 9], + [10, 10], + ], + }, + snapped_waypoints: { + coordinates: [ + [0, 0], + [5, 5], + [10, 10], + ], + }, + details: { + time: [ + [0, 1, 180000], + [1, 2, 180000], + [2, 3, 180000], + [3, 4, 180000], + [4, 7, 540000], + [7, 9, 360000], + [9, 10, 180000], + ], + }, + instructions: [ + { + distance: 25000, + sign: 0, + interval: [0, 5], + text: 'Some instructions', + time: 900000, + }, + { + distance: 0, + sign: 5, + interval: [5, 5], + text: 'Waypoint 1', + time: 0, + }, + { + distance: 25000, + sign: 2, + interval: [5, 10], + text: 'Some instructions', + time: 900000, + }, + { + distance: 0.0, + sign: 4, + interval: [10, 10], + text: 'Arrive at destination', + time: 0, + }, + ], + }, + ], + }, + }); + }), +}; + +const mockMatcherGeodesic = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + inverse: jest.fn().mockImplementation(() => ({ + azimuth: 45, + distance: 50000, + })), +}; + +describe('Graphhopper Georouter', () => { + let georouterCreator: GeorouterCreator; + let graphhopperGeorouter: IGeorouter; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + GeorouterCreator, + { + provide: HttpService, + useValue: mockHttpService, + }, + { + provide: MatcherGeodesic, + useValue: mockMatcherGeodesic, + }, + ], + }).compile(); + + georouterCreator = module.get(GeorouterCreator); + graphhopperGeorouter = georouterCreator.create( + 'graphhopper', + 'http://localhost', + ); + }); + + it('should be defined', () => { + expect(graphhopperGeorouter).toBeDefined(); + }); + + describe('route function', () => { + it('should fail on axios error', async () => { + await expect( + graphhopperGeorouter.route( + [ + { + key: 'route1', + points: [ + { + lat: 0, + lon: 0, + }, + { + lat: 1, + lon: 1, + }, + ], + }, + ], + { + withDistance: false, + withPoints: false, + withTime: false, + }, + ), + ).rejects.toBeInstanceOf(Error); + }); + + it('should create one route with all settings to false', async () => { + const routes = await graphhopperGeorouter.route( + [ + { + key: 'route1', + points: [ + { + lat: 0, + lon: 0, + }, + { + lat: 10, + lon: 10, + }, + ], + }, + ], + { + withDistance: false, + withPoints: false, + withTime: false, + }, + ); + expect(routes).toHaveLength(1); + expect(routes[0].route.distance).toBe(50000); + }); + + it('should create one route with points', async () => { + const routes = await graphhopperGeorouter.route( + [ + { + key: 'route1', + points: [ + { + lat: 0, + lon: 0, + }, + { + lat: 10, + lon: 10, + }, + ], + }, + ], + { + withDistance: false, + withPoints: true, + withTime: false, + }, + ); + expect(routes).toHaveLength(1); + expect(routes[0].route.distance).toBe(50000); + expect(routes[0].route.duration).toBe(1800); + expect(routes[0].route.fwdAzimuth).toBe(45); + expect(routes[0].route.backAzimuth).toBe(225); + expect(routes[0].route.points.length).toBe(11); + }); + + it('should create one route with points and time', async () => { + const routes = await graphhopperGeorouter.route( + [ + { + key: 'route1', + points: [ + { + lat: 0, + lon: 0, + }, + { + lat: 10, + lon: 10, + }, + ], + }, + ], + { + withDistance: false, + withPoints: true, + withTime: true, + }, + ); + expect(routes).toHaveLength(1); + expect(routes[0].route.spacetimePoints.length).toBe(2); + expect(routes[0].route.spacetimePoints[1].duration).toBe(1800); + expect(routes[0].route.spacetimePoints[1].distance).toBeUndefined(); + }); + + it('should create one route with points and missed waypoints extrapolations', async () => { + const routes = await graphhopperGeorouter.route( + [ + { + key: 'route1', + points: [ + { + lat: 0, + lon: 0, + }, + { + lat: 5, + lon: 5, + }, + { + lat: 10, + lon: 10, + }, + ], + }, + ], + { + withDistance: false, + withPoints: true, + withTime: true, + }, + ); + expect(routes).toHaveLength(1); + expect(routes[0].route.spacetimePoints.length).toBe(3); + expect(routes[0].route.distance).toBe(50000); + expect(routes[0].route.duration).toBe(1800); + expect(routes[0].route.fwdAzimuth).toBe(45); + expect(routes[0].route.backAzimuth).toBe(225); + expect(routes[0].route.points.length).toBe(9); + }); + + it('should create one route with points, time and distance', async () => { + const routes = await graphhopperGeorouter.route( + [ + { + key: 'route1', + points: [ + { + lat: 0, + lon: 0, + }, + { + lat: 10, + lon: 10, + }, + ], + }, + ], + { + withDistance: true, + withPoints: true, + withTime: true, + }, + ); + expect(routes).toHaveLength(1); + expect(routes[0].route.spacetimePoints.length).toBe(3); + expect(routes[0].route.spacetimePoints[1].duration).toBe(990); + expect(routes[0].route.spacetimePoints[1].distance).toBe(25000); + }); + }); +}); diff --git a/src/modules/matcher/tests/unit/adapters/secondaries/messager.spec.ts b/src/modules/matcher/tests/unit/adapters/secondaries/messager.spec.ts new file mode 100644 index 0000000..0bd23a9 --- /dev/null +++ b/src/modules/matcher/tests/unit/adapters/secondaries/messager.spec.ts @@ -0,0 +1,47 @@ +import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Messager } from '../../../../adapters/secondaries/messager'; + +const mockAmqpConnection = { + publish: jest.fn().mockImplementation(), +}; + +const mockConfigService = { + get: jest.fn().mockResolvedValue({ + RMQ_EXCHANGE: 'mobicoop', + }), +}; + +describe('Messager', () => { + let messager: Messager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + providers: [ + Messager, + { + provide: AmqpConnection, + useValue: mockAmqpConnection, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + messager = module.get(Messager); + }); + + it('should be defined', () => { + expect(messager).toBeDefined(); + }); + + it('should publish a message', async () => { + jest.spyOn(mockAmqpConnection, 'publish'); + messager.publish('test.create.info', 'my-test'); + expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/matcher/tests/unit/domain/ecosystem/geography.spec.ts b/src/modules/matcher/tests/unit/domain/ecosystem/geography.spec.ts new file mode 100644 index 0000000..e4d20a1 --- /dev/null +++ b/src/modules/matcher/tests/unit/domain/ecosystem/geography.spec.ts @@ -0,0 +1,282 @@ +import { Person } from '../../../../domain/entities/ecosystem/person'; +import { + Geography, + RouteKey, +} from '../../../../domain/entities/ecosystem/geography'; +import { Role } from '../../../../domain/types/role.enum'; +import { NamedRoute } from '../../../../domain/entities/ecosystem/named-route'; +import { Route } from '../../../../domain/entities/ecosystem/route'; +import { IGeodesic } from '../../../../domain/interfaces/geodesic.interface'; +import { PointType } from '../../../../domain/types/geography.enum'; + +const person: Person = new Person( + { + identifier: 1, + }, + 0, + 900, +); + +const mockGeodesic: IGeodesic = { + inverse: jest.fn().mockImplementation(() => ({ + azimuth: 45, + distance: 50000, + })), +}; + +const mockGeorouter = { + route: jest + .fn() + .mockImplementationOnce(() => { + return [ + { + key: RouteKey.COMMON, + route: new Route(mockGeodesic), + }, + ]; + }) + .mockImplementationOnce(() => { + return [ + { + key: RouteKey.DRIVER, + route: new Route(mockGeodesic), + }, + { + key: RouteKey.PASSENGER, + route: new Route(mockGeodesic), + }, + ]; + }) + .mockImplementationOnce(() => { + return [ + { + key: RouteKey.DRIVER, + route: new Route(mockGeodesic), + }, + ]; + }) + .mockImplementationOnce(() => { + return [ + { + key: RouteKey.PASSENGER, + route: new Route(mockGeodesic), + }, + ]; + }), +}; + +describe('Geography entity', () => { + it('should be defined', () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ], + }, + 'Europe/Paris', + person, + ); + expect(geography).toBeDefined(); + }); + + describe('init', () => { + it('should initialize a geography request with point types', () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + type: PointType.LOCALITY, + }, + { + lat: 50.630992, + lon: 3.045432, + type: PointType.LOCALITY, + }, + ], + }, + 'Europe/Paris', + person, + ); + geography.init(); + expect(geography._points.length).toBe(2); + expect(geography.originType).toBe(PointType.LOCALITY); + expect(geography.destinationType).toBe(PointType.LOCALITY); + }); + it('should throw an exception if waypoints are empty', () => { + const geography = new Geography( + { + waypoints: [], + }, + 'Europe/Paris', + person, + ); + expect(() => geography.init()).toThrow(); + }); + it('should throw an exception if only one waypoint is provided', () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + }, + ], + }, + 'Europe/Paris', + person, + ); + expect(() => geography.init()).toThrow(); + }); + it('should throw an exception if a waypoint has invalid longitude', () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 201.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ], + }, + 'Europe/Paris', + person, + ); + expect(() => geography.init()).toThrow(); + }); + it('should throw an exception if a waypoint has invalid latitude', () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 250.630992, + lon: 3.045432, + }, + ], + }, + 'Europe/Paris', + person, + ); + expect(() => geography.init()).toThrow(); + }); + }); + + describe('create route', () => { + it('should create routes as driver and passenger', async () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ], + }, + 'Europe/Paris', + person, + ); + geography.init(); + await geography.createRoutes( + [Role.DRIVER, Role.PASSENGER], + mockGeorouter, + ); + expect(geography.driverRoute.waypoints.length).toBe(2); + expect(geography.passengerRoute.waypoints.length).toBe(2); + }); + + it('should create routes as driver and passenger with 3 waypoints', async () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 49.781215, + lon: 2.198475, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ], + }, + 'Europe/Paris', + person, + ); + geography.init(); + await geography.createRoutes( + [Role.DRIVER, Role.PASSENGER], + mockGeorouter, + ); + expect(geography.driverRoute.waypoints.length).toBe(3); + expect(geography.passengerRoute.waypoints.length).toBe(2); + }); + + it('should create routes as driver', async () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ], + }, + 'Europe/Paris', + person, + ); + geography.init(); + await geography.createRoutes([Role.DRIVER], mockGeorouter); + expect(geography.driverRoute.waypoints.length).toBe(2); + expect(geography.passengerRoute).toBeUndefined(); + }); + + it('should create routes as passenger', async () => { + const geography = new Geography( + { + waypoints: [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ], + }, + 'Europe/Paris', + person, + ); + geography.init(); + await geography.createRoutes([Role.PASSENGER], mockGeorouter); + expect(geography.passengerRoute.waypoints.length).toBe(2); + expect(geography.driverRoute).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/matcher/tests/unit/domain/ecosystem/person.spec.ts b/src/modules/matcher/tests/unit/domain/ecosystem/person.spec.ts new file mode 100644 index 0000000..c9d604c --- /dev/null +++ b/src/modules/matcher/tests/unit/domain/ecosystem/person.spec.ts @@ -0,0 +1,40 @@ +import { Person } from '../../../../domain/entities/ecosystem/person'; + +const DEFAULT_IDENTIFIER = 0; +const MARGIN_DURATION = 900; + +describe('Person entity', () => { + it('should be defined', () => { + const person = new Person( + { + identifier: 1, + }, + DEFAULT_IDENTIFIER, + MARGIN_DURATION, + ); + expect(person).toBeDefined(); + }); + + describe('init', () => { + it('should initialize a person with an identifier', () => { + const person = new Person( + { + identifier: 1, + }, + DEFAULT_IDENTIFIER, + MARGIN_DURATION, + ); + person.init(); + expect(person.identifier).toBe(1); + expect(person.marginDurations[0]).toBe(900); + expect(person.marginDurations[6]).toBe(900); + }); + it('should initialize a person without an identifier', () => { + const person = new Person({}, DEFAULT_IDENTIFIER, MARGIN_DURATION); + person.init(); + expect(person.identifier).toBe(0); + expect(person.marginDurations[0]).toBe(900); + expect(person.marginDurations[6]).toBe(900); + }); + }); +}); diff --git a/src/modules/matcher/tests/unit/domain/ecosystem/route.spec.ts b/src/modules/matcher/tests/unit/domain/ecosystem/route.spec.ts new file mode 100644 index 0000000..16d27a3 --- /dev/null +++ b/src/modules/matcher/tests/unit/domain/ecosystem/route.spec.ts @@ -0,0 +1,65 @@ +import { Route } from '../../../../domain/entities/ecosystem/route'; +import { SpacetimePoint } from '../../../../domain/entities/ecosystem/spacetime-point'; +import { Waypoint } from '../../../../domain/entities/ecosystem/waypoint'; + +const mockGeodesic = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + inverse: jest.fn().mockImplementation((lon1, lat1, lon2, lat2) => { + return lon1 == 0 + ? { + azimuth: 45, + distance: 50000, + } + : { + azimuth: -45, + distance: 60000, + }; + }), +}; + +describe('Route entity', () => { + it('should be defined', () => { + const route = new Route(mockGeodesic); + expect(route).toBeDefined(); + }); + it('should set waypoints and geodesic values for a route', () => { + const route = new Route(mockGeodesic); + const waypoint1: Waypoint = new Waypoint({ + lon: 0, + lat: 0, + }); + const waypoint2: Waypoint = new Waypoint({ + lon: 10, + lat: 10, + }); + route.setWaypoints([waypoint1, waypoint2]); + expect(route.waypoints.length).toBe(2); + expect(route.fwdAzimuth).toBe(45); + expect(route.backAzimuth).toBe(225); + expect(route.distanceAzimuth).toBe(50000); + }); + it('should set points and geodesic values for a route', () => { + const route = new Route(mockGeodesic); + route.setPoints([ + { + lon: 10, + lat: 10, + }, + { + lon: 20, + lat: 20, + }, + ]); + expect(route.points.length).toBe(2); + expect(route.fwdAzimuth).toBe(315); + expect(route.backAzimuth).toBe(135); + expect(route.distanceAzimuth).toBe(60000); + }); + it('should set spacetimePoints for a route', () => { + const route = new Route(mockGeodesic); + const spacetimePoint1 = new SpacetimePoint([0, 0], 0, 0); + const spacetimePoint2 = new SpacetimePoint([10, 10], 500, 5000); + route.setSpacetimePoints([spacetimePoint1, spacetimePoint2]); + expect(route.spacetimePoints.length).toBe(2); + }); +}); diff --git a/src/modules/matcher/tests/unit/domain/ecosystem/time.spec.ts b/src/modules/matcher/tests/unit/domain/ecosystem/time.spec.ts new file mode 100644 index 0000000..fa5772e --- /dev/null +++ b/src/modules/matcher/tests/unit/domain/ecosystem/time.spec.ts @@ -0,0 +1,186 @@ +import { Time } from '../../../../domain/entities/ecosystem/time'; + +const MARGIN_DURATION = 900; +const VALIDITY_DURATION = 365; + +describe('Time entity', () => { + it('should be defined', () => { + const time = new Time( + { + departure: '2023-04-01 12:24:00', + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(time).toBeDefined(); + }); + + describe('init', () => { + it('should initialize a punctual time request', () => { + const time = new Time( + { + departure: '2023-04-01 12:24:00', + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + time.init(); + expect(time.fromDate.getFullYear()).toBe( + new Date('2023-04-01 12:24:00').getFullYear(), + ); + }); + it('should initialize a punctual time request with specific single margin duration', () => { + const time = new Time( + { + departure: '2023-04-01 12:24:00', + marginDuration: 300, + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + time.init(); + expect(time.marginDurations['tue']).toBe(300); + }); + it('should initialize a punctual time request with specific margin durations', () => { + const time = new Time( + { + departure: '2023-04-01 12:24:00', + marginDurations: { + sat: 350, + }, + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + time.init(); + expect(time.marginDurations['tue']).toBe(900); + expect(time.marginDurations['sat']).toBe(350); + }); + it('should initialize a punctual time request with specific single margin duration and margin durations', () => { + const time = new Time( + { + departure: '2023-04-01 12:24:00', + marginDuration: 500, + marginDurations: { + sat: 350, + }, + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + time.init(); + expect(time.marginDurations['tue']).toBe(500); + expect(time.marginDurations['sat']).toBe(350); + }); + it('should initialize a recurrent time request', () => { + const time = new Time( + { + fromDate: '2023-04-01', + schedule: { + mon: '12:00', + }, + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + time.init(); + expect(time.fromDate.getFullYear()).toBe( + new Date('2023-04-01').getFullYear(), + ); + }); + it('should throw an exception if no date is provided', () => { + const time = new Time({}, MARGIN_DURATION, VALIDITY_DURATION); + expect(() => time.init()).toThrow(); + }); + it('should throw an exception if punctual date is invalid', () => { + const time = new Time( + { + departure: '2023-15-01 12:24:00', + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); + it('should throw an exception if recurrent fromDate is invalid', () => { + const time = new Time( + { + fromDate: '2023-15-01', + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); + it('should throw an exception if recurrent toDate is invalid', () => { + const time = new Time( + { + fromDate: '2023-04-01', + toDate: '2023-13-01', + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); + it('should throw an exception if recurrent toDate is before fromDate', () => { + const time = new Time( + { + fromDate: '2023-04-01', + toDate: '2023-03-01', + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); + it('should throw an exception if schedule is missing', () => { + const time = new Time( + { + fromDate: '2023-04-01', + toDate: '2024-03-31', + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); + it('should throw an exception if schedule is empty', () => { + const time = new Time( + { + fromDate: '2023-04-01', + toDate: '2024-03-31', + schedule: {}, + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); + it('should throw an exception if schedule is invalid', () => { + const time = new Time( + { + fromDate: '2023-04-01', + toDate: '2024-03-31', + schedule: { + mon: '32:78', + }, + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); + }); + it('should throw an exception if margin durations is provided but empty', () => { + const time = new Time( + { + departure: '2023-04-01 12:24:00', + marginDurations: {}, + }, + MARGIN_DURATION, + VALIDITY_DURATION, + ); + expect(() => time.init()).toThrow(); + }); +}); diff --git a/src/modules/matcher/tests/unit/domain/match.usecase.spec.ts b/src/modules/matcher/tests/unit/domain/match.usecase.spec.ts new file mode 100644 index 0000000..6de7ad9 --- /dev/null +++ b/src/modules/matcher/tests/unit/domain/match.usecase.spec.ts @@ -0,0 +1,89 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Messager } from '../../../adapters/secondaries/messager'; +import { MatchUseCase } from '../../../domain/usecases/match.usecase'; +import { MatchRequest } from '../../../domain/dtos/match.request'; +import { MatchQuery } from '../../../queries/match.query'; +import { AdRepository } from '../../../adapters/secondaries/ad.repository'; +import { AutomapperModule } from '@automapper/nestjs'; +import { classes } from '@automapper/classes'; +import { IDefaultParams } from '../../../domain/types/default-params.type'; +import { Algorithm } from '../../../domain/types/algorithm.enum'; + +const mockAdRepository = {}; + +const mockMessager = { + publish: jest.fn().mockImplementation(), +}; + +const mockGeorouterCreator = { + create: jest.fn().mockImplementation(), +}; + +const defaultParams: IDefaultParams = { + DEFAULT_IDENTIFIER: 0, + MARGIN_DURATION: 900, + VALIDITY_DURATION: 365, + DEFAULT_TIMEZONE: 'Europe/Paris', + DEFAULT_SEATS: 3, + DEFAULT_ALGORITHM_SETTINGS: { + algorithm: Algorithm.CLASSIC, + strict: false, + remoteness: 15000, + useProportion: true, + proportion: 0.3, + useAzimuth: true, + azimuthMargin: 10, + maxDetourDistanceRatio: 0.3, + maxDetourDurationRatio: 0.3, + georouterType: 'graphhopper', + georouterUrl: 'http://localhost', + }, +}; + +describe('MatchUseCase', () => { + let matchUseCase: MatchUseCase; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })], + providers: [ + { + provide: AdRepository, + useValue: mockAdRepository, + }, + { + provide: Messager, + useValue: mockMessager, + }, + MatchUseCase, + ], + }).compile(); + + matchUseCase = module.get(MatchUseCase); + }); + + it('should be defined', () => { + expect(matchUseCase).toBeDefined(); + }); + + describe('execute', () => { + it('should return matches', async () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.waypoints = [ + { + lon: 1.093912, + lat: 49.440041, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + matchRequest.departure = '2023-04-01 12:23:00'; + const matches = await matchUseCase.execute( + new MatchQuery(matchRequest, defaultParams, mockGeorouterCreator), + ); + expect(matches.total).toBe(1); + }); + }); +}); diff --git a/src/modules/matcher/tests/unit/queries/match.query.spec.ts b/src/modules/matcher/tests/unit/queries/match.query.spec.ts new file mode 100644 index 0000000..8ed650b --- /dev/null +++ b/src/modules/matcher/tests/unit/queries/match.query.spec.ts @@ -0,0 +1,210 @@ +import { MatchRequest } from '../../../domain/dtos/match.request'; +import { Role } from '../../../domain/types/role.enum'; +import { TimingFrequency } from '../../../domain/types/timing'; +import { IDefaultParams } from '../../../domain/types/default-params.type'; +import { MatchQuery } from '../../../queries/match.query'; +import { Algorithm } from '../../../domain/types/algorithm.enum'; + +const defaultParams: IDefaultParams = { + DEFAULT_IDENTIFIER: 0, + MARGIN_DURATION: 900, + VALIDITY_DURATION: 365, + DEFAULT_TIMEZONE: 'Europe/Paris', + DEFAULT_SEATS: 3, + DEFAULT_ALGORITHM_SETTINGS: { + algorithm: Algorithm.CLASSIC, + strict: false, + remoteness: 15000, + useProportion: true, + proportion: 0.3, + useAzimuth: true, + azimuthMargin: 10, + maxDetourDistanceRatio: 0.3, + maxDetourDurationRatio: 0.3, + georouterType: 'graphhopper', + georouterUrl: 'http://localhost', + }, +}; + +const mockGeorouterCreator = { + create: jest.fn().mockImplementation(), +}; + +describe('Match query', () => { + it('should be defined', () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.departure = '2023-04-01 12:00'; + matchRequest.waypoints = [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + const matchQuery: MatchQuery = new MatchQuery( + matchRequest, + defaultParams, + mockGeorouterCreator, + ); + expect(matchQuery).toBeDefined(); + }); + + it('should create a query with excluded identifiers', () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.departure = '2023-04-01 12:00'; + matchRequest.waypoints = [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + matchRequest.identifier = 125; + matchRequest.exclusions = [126, 127, 128]; + const matchQuery: MatchQuery = new MatchQuery( + matchRequest, + defaultParams, + mockGeorouterCreator, + ); + expect(matchQuery.exclusions.length).toBe(4); + }); + + it('should create a query with driver role only', () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.departure = '2023-04-01 12:00'; + matchRequest.waypoints = [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + matchRequest.driver = true; + const matchQuery: MatchQuery = new MatchQuery( + matchRequest, + defaultParams, + mockGeorouterCreator, + ); + expect(matchQuery.roles).toEqual([Role.DRIVER]); + }); + + it('should create a query with passenger role only', () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.departure = '2023-04-01 12:00'; + matchRequest.waypoints = [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + matchRequest.passenger = true; + const matchQuery: MatchQuery = new MatchQuery( + matchRequest, + defaultParams, + mockGeorouterCreator, + ); + expect(matchQuery.roles).toEqual([Role.PASSENGER]); + }); + + it('should create a query with driver and passenger roles', () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.departure = '2023-04-01 12:00'; + matchRequest.waypoints = [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + matchRequest.passenger = true; + matchRequest.driver = true; + const matchQuery: MatchQuery = new MatchQuery( + matchRequest, + defaultParams, + mockGeorouterCreator, + ); + expect(matchQuery.roles.length).toBe(2); + expect(matchQuery.roles).toContain(Role.PASSENGER); + expect(matchQuery.roles).toContain(Role.DRIVER); + }); + + it('should create a query with number of seats modified', () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.departure = '2023-04-01 12:00'; + matchRequest.waypoints = [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + matchRequest.seatsDriver = 1; + matchRequest.seatsPassenger = 2; + const matchQuery: MatchQuery = new MatchQuery( + matchRequest, + defaultParams, + mockGeorouterCreator, + ); + expect(matchQuery.requirement.seatsDriver).toBe(1); + expect(matchQuery.requirement.seatsPassenger).toBe(2); + }); + + it('should create a query with modified algorithm settings', () => { + const matchRequest: MatchRequest = new MatchRequest(); + matchRequest.departure = '2023-04-01 12:00'; + matchRequest.waypoints = [ + { + lat: 49.440041, + lon: 1.093912, + }, + { + lat: 50.630992, + lon: 3.045432, + }, + ]; + matchRequest.algorithm = Algorithm.CLASSIC; + matchRequest.strict = true; + matchRequest.useProportion = true; + matchRequest.proportion = 0.45; + matchRequest.useAzimuth = true; + matchRequest.azimuthMargin = 15; + matchRequest.remoteness = 20000; + matchRequest.maxDetourDistanceRatio = 0.41; + matchRequest.maxDetourDurationRatio = 0.42; + const matchQuery: MatchQuery = new MatchQuery( + matchRequest, + defaultParams, + mockGeorouterCreator, + ); + expect(matchQuery.algorithmSettings.algorithm).toBe(Algorithm.CLASSIC); + expect(matchQuery.algorithmSettings.restrict).toBe( + TimingFrequency.FREQUENCY_PUNCTUAL, + ); + expect(matchQuery.algorithmSettings.useProportion).toBeTruthy(); + expect(matchQuery.algorithmSettings.proportion).toBe(0.45); + expect(matchQuery.algorithmSettings.useAzimuth).toBeTruthy(); + expect(matchQuery.algorithmSettings.azimuthMargin).toBe(15); + expect(matchQuery.algorithmSettings.remoteness).toBe(20000); + expect(matchQuery.algorithmSettings.maxDetourDistanceRatio).toBe(0.41); + expect(matchQuery.algorithmSettings.maxDetourDurationRatio).toBe(0.42); + }); +}); diff --git a/src/modules/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts b/src/modules/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts index 5df3e07..911466c 100644 --- a/src/modules/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts +++ b/src/modules/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts @@ -1,6 +1,6 @@ import { ArgumentMetadata } from '@nestjs/common'; -import { UpdateTerritoryRequest } from '../../../modules/territory/domain/dtos/update-territory.request'; import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe'; +import { MatchRequest } from '../../../matcher/domain/dtos/match.request'; describe('RpcValidationPipe', () => { it('should not validate request', async () => { @@ -10,13 +10,11 @@ describe('RpcValidationPipe', () => { }); const metadata: ArgumentMetadata = { type: 'body', - metatype: UpdateTerritoryRequest, + metatype: MatchRequest, data: '', }; - await target - .transform({}, metadata) - .catch((err) => { - expect(err.message).toEqual('Rpc Exception'); - }); + await target.transform({}, metadata).catch((err) => { + expect(err.message).toEqual('Rpc Exception'); + }); }); });