diff --git a/README.en.md b/README.en.md index 2932b46..99cf475 100644 --- a/README.en.md +++ b/README.en.md @@ -12,13 +12,11 @@ Pulpito is a side project that helped me learn the basics of Node.JS, Express, P ## Deploy -Pulpito API is available at https://pulpito-app.herokuapp.com/api/v1/ - Pulpito webapp is currently deployed on Heroku at https://pulpito-app.herokuapp.com/ -## API Documentation +## API -API documentation is available here : https://documenter.getpostman.com/view/18011617/2s8YYCt52e +Pulpito also comes with an API (actually with more features than the webapp), please check API documentation to see the different API routes : https://documenter.getpostman.com/view/18011617/2s8YYCt52e ## Tests diff --git a/jest.config.json b/jest.config.json index 83009fa..b16ba41 100644 --- a/jest.config.json +++ b/jest.config.json @@ -2,12 +2,12 @@ "verbose": true, "setupFiles": ["dotenv/config"], "collectCoverageFrom": [ - "src/**/*.{js|ts}", - "!public/**/*.{js|ts}", - "!src/*.{js|ts}", - "!coverage/**/*.{js|ts}", + "src/**/*.ts", + "!public/**/*.ts", + "!src/*.ts", + "!coverage/**/*.ts", "!**/node_modules/**", - "!dev-data/**/*.{js|ts}" + "!dev-data/**/*.ts" ], "preset": "ts-jest", "testEnvironment": "node", diff --git a/package-lock.json b/package-lock.json index 366f772..9be3c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulpito", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pulpito", - "version": "2.0.1", + "version": "2.1.0", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -21,21 +21,39 @@ "express-rate-limit": "^6.4.0", "helmet": "^5.0.2", "hpp": "^0.2.3", - "jsonwebtoken": "^8.5.1", + "jsonwebtoken": "^9.0.0", + "lodash.clonedeep": "^4.5.0", + "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", "morgan": "^1.10.0", "nodemailer": "^6.7.5", "pug": "^3.0.2", + "save-dev": "^0.0.1-security", "utf8": "^3.0.0", "validator": "^13.7.0", - "xss-clean": "^0.1.1" + "xss-clean": "^0.1.1", + "xss-filters": "^1.2.7" }, "devDependencies": { "@faker-js/faker": "^7.2.0", + "@types/bcryptjs": "^2.4.2", "@types/express": "^4.17.17", + "@types/express-serve-static-core": "^4.17.33", + "@types/hpp": "^0.2.2", "@types/jest": "^29.4.0", + "@types/jsonwebtoken": "^9.0.1", + "@types/lodash": "^4.14.191", + "@types/lodash.clonedeep": "^4.5.7", + "@types/lodash.groupby": "^4.6.7", + "@types/luxon": "^3.2.0", + "@types/morgan": "^1.9.4", "@types/node": "^18.13.0", + "@types/nodemailer": "^6.4.7", + "@types/supertest": "^2.0.12", + "@types/utf8": "^3.0.1", + "@types/validator": "^13.7.12", + "@types/xss-filters": "^0.0.27", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", "concurrently": "^7.6.0", @@ -1362,6 +1380,12 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.2.tgz", + "integrity": "sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==", + "dev": true + }, "node_modules/@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -1381,6 +1405,12 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", @@ -1413,6 +1443,15 @@ "@types/node": "*" } }, + "node_modules/@types/hpp": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@types/hpp/-/hpp-0.2.2.tgz", + "integrity": "sha512-BLgsawqFFbS3tFUr+mcBRfst+DumnSfi4PgyNeJAGk0eIxm7lKX1axmHVlbgKNAZS0caZA5/LSopuj0T2LKRPw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "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", @@ -1453,17 +1492,74 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.1.tgz", + "integrity": "sha512-c5ltxazpWabia/4UzhIoaDcIza4KViOQhdbjRlfcIGVnsE3c3brkz9Z+F/EeJIECOQP7W7US2hNE930cWWkPiw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==", + "dev": true + }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz", + "integrity": "sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.groupby": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/@types/lodash.groupby/-/lodash.groupby-4.6.7.tgz", + "integrity": "sha512-dFUR1pqdMgjIBbgPJ/8axJX6M1C7zsL+HF4qdYMQeJ7XOp0Qbf37I3zh9gpXr/ks6tgEYPDRqyZRAnFYvewYHQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/luxon": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz", + "integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==", + "dev": true + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==", "dev": true }, + "node_modules/@types/morgan": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.4.tgz", + "integrity": "sha512-cXoc4k+6+YAllH3ZHmx4hf7La1dzUk6keTR4bF4b4Sc0mZxU/zK4wO7l+ZzezXm/jkYj/qC+uYGZrarZdIVvyQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "18.13.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz", "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", + "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prettier": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", @@ -1504,6 +1600,37 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/superagent": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.16.tgz", + "integrity": "sha512-tLfnlJf6A5mB6ddqF159GqcDizfzbMUB1/DeT59/wBNqzRTNNKsaw79A/1TZ84X+f/EwWH8FeuSkjlCLyqS/zQ==", + "dev": true, + "dependencies": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "node_modules/@types/supertest": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.12.tgz", + "integrity": "sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ==", + "dev": true, + "dependencies": { + "@types/superagent": "*" + } + }, + "node_modules/@types/utf8": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/utf8/-/utf8-3.0.1.tgz", + "integrity": "sha512-1EkWuw7rT3BMz2HpmcEOr/HL61mWNA6Ulr/KdbXR9AI0A55wD4Qfv8hizd8Q1DnknSIzzDvQmvvY/guvX7jjZA==", + "dev": true + }, + "node_modules/@types/validator": { + "version": "13.7.12", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.12.tgz", + "integrity": "sha512-YVtyAPqpefU+Mm/qqnOANW6IkqKpCSrarcyV269C8MA8Ux0dbkEuQwM/4CjL47kVEM2LgBef/ETfkH+c6+moFA==", + "dev": true + }, "node_modules/@types/webidl-conversions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz", @@ -1518,6 +1645,12 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/xss-filters": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/@types/xss-filters/-/xss-filters-0.0.27.tgz", + "integrity": "sha512-ctN3f7vl4tBXa+W11hm0oDwp67K6SYK07h4OmNgaEoIOVJ/rksnc2prpbjK+Ju3/fYIa3HQaH4x9Y525CXFOow==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.22", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz", @@ -2340,7 +2473,7 @@ "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -4777,24 +4910,18 @@ } }, "node_modules/jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", "dependencies": { "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", + "lodash": "^4.17.21", "ms": "^2.1.1", - "semver": "^5.6.0" + "semver": "^7.3.8" }, "engines": { - "node": ">=4", - "npm": ">=1.4.28" + "node": ">=12", + "npm": ">=6" } }, "node_modules/jsonwebtoken/node_modules/ms": { @@ -4802,6 +4929,20 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jstransformer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", @@ -4892,35 +5033,15 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -4934,16 +5055,10 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -6105,10 +6220,16 @@ "node": ">=6" } }, + "node_modules/save-dev": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/save-dev/-/save-dev-0.0.1-security.tgz", + "integrity": "sha512-k6knZTDNK8PKKbIqnvxiOveJinuw2LcQjqDoaorZWP9M5AR2EPsnpDeSbeoZZ0pHr5ze1uoaKdK8NBGQrJ34Uw==" + }, "node_modules/semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, "bin": { "semver": "bin/semver" } @@ -6997,10 +7118,15 @@ "xss-filters": "1.2.6" } }, - "node_modules/xss-filters": { + "node_modules/xss-clean/node_modules/xss-filters": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.6.tgz", - "integrity": "sha1-aLOQicsd/4udvIiUhIObL1B/XFU=" + "integrity": "sha512-uqgwZRpVJCDfHsRX9lDrkPyCitQYzPklmLSbajJncATZKAUd1tF1x9y2VyPNFMv8SsSWed80xorSS5qGpw3WiA==" + }, + "node_modules/xss-filters": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.7.tgz", + "integrity": "sha512-KzcmYT/f+YzcYrYRqw6mXxd25BEZCxBQnf+uXTopQDIhrmiaLwO+f+yLsIvvNlPhYvgff8g3igqrBxYh9k8NbQ==" }, "node_modules/y18n": { "version": "5.0.8", @@ -7014,8 +7140,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yargs": { "version": "17.6.2", @@ -8073,6 +8198,12 @@ "@babel/types": "^7.3.0" } }, + "@types/bcryptjs": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.2.tgz", + "integrity": "sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==", + "dev": true + }, "@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -8092,6 +8223,12 @@ "@types/node": "*" } }, + "@types/cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", + "dev": true + }, "@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", @@ -8124,6 +8261,15 @@ "@types/node": "*" } }, + "@types/hpp": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@types/hpp/-/hpp-0.2.2.tgz", + "integrity": "sha512-BLgsawqFFbS3tFUr+mcBRfst+DumnSfi4PgyNeJAGk0eIxm7lKX1axmHVlbgKNAZS0caZA5/LSopuj0T2LKRPw==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -8164,17 +8310,74 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "@types/jsonwebtoken": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.1.tgz", + "integrity": "sha512-c5ltxazpWabia/4UzhIoaDcIza4KViOQhdbjRlfcIGVnsE3c3brkz9Z+F/EeJIECOQP7W7US2hNE930cWWkPiw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==", + "dev": true + }, + "@types/lodash.clonedeep": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz", + "integrity": "sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.groupby": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/@types/lodash.groupby/-/lodash.groupby-4.6.7.tgz", + "integrity": "sha512-dFUR1pqdMgjIBbgPJ/8axJX6M1C7zsL+HF4qdYMQeJ7XOp0Qbf37I3zh9gpXr/ks6tgEYPDRqyZRAnFYvewYHQ==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/luxon": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz", + "integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==", + "dev": true + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==", "dev": true }, + "@types/morgan": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.4.tgz", + "integrity": "sha512-cXoc4k+6+YAllH3ZHmx4hf7La1dzUk6keTR4bF4b4Sc0mZxU/zK4wO7l+ZzezXm/jkYj/qC+uYGZrarZdIVvyQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "18.13.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz", "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==" }, + "@types/nodemailer": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz", + "integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/prettier": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", @@ -8215,6 +8418,37 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/superagent": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.16.tgz", + "integrity": "sha512-tLfnlJf6A5mB6ddqF159GqcDizfzbMUB1/DeT59/wBNqzRTNNKsaw79A/1TZ84X+f/EwWH8FeuSkjlCLyqS/zQ==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.12.tgz", + "integrity": "sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ==", + "dev": true, + "requires": { + "@types/superagent": "*" + } + }, + "@types/utf8": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/utf8/-/utf8-3.0.1.tgz", + "integrity": "sha512-1EkWuw7rT3BMz2HpmcEOr/HL61mWNA6Ulr/KdbXR9AI0A55wD4Qfv8hizd8Q1DnknSIzzDvQmvvY/guvX7jjZA==", + "dev": true + }, + "@types/validator": { + "version": "13.7.12", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.12.tgz", + "integrity": "sha512-YVtyAPqpefU+Mm/qqnOANW6IkqKpCSrarcyV269C8MA8Ux0dbkEuQwM/4CjL47kVEM2LgBef/ETfkH+c6+moFA==", + "dev": true + }, "@types/webidl-conversions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz", @@ -8229,6 +8463,12 @@ "@types/webidl-conversions": "*" } }, + "@types/xss-filters": { + "version": "0.0.27", + "resolved": "https://registry.npmjs.org/@types/xss-filters/-/xss-filters-0.0.27.tgz", + "integrity": "sha512-ctN3f7vl4tBXa+W11hm0oDwp67K6SYK07h4OmNgaEoIOVJ/rksnc2prpbjK+Ju3/fYIa3HQaH4x9Y525CXFOow==", + "dev": true + }, "@types/yargs": { "version": "17.0.22", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz", @@ -8789,7 +9029,7 @@ "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, "buffer-from": { "version": "1.1.2", @@ -10592,26 +10832,28 @@ "dev": true }, "jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", "requires": { "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", + "lodash": "^4.17.21", "ms": "^2.1.1", - "semver": "^5.6.0" + "semver": "^7.3.8" }, "dependencies": { "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "requires": { + "lru-cache": "^6.0.0" + } } } }, @@ -10690,35 +10932,15 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" - }, - "lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + "lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" }, "lodash.memoize": { "version": "4.1.2", @@ -10732,16 +10954,10 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" - }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -11603,10 +11819,16 @@ "sparse-bitfield": "^3.0.3" } }, + "save-dev": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/save-dev/-/save-dev-0.0.1-security.tgz", + "integrity": "sha512-k6knZTDNK8PKKbIqnvxiOveJinuw2LcQjqDoaorZWP9M5AR2EPsnpDeSbeoZZ0pHr5ze1uoaKdK8NBGQrJ34Uw==" + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true }, "send": { "version": "0.17.2", @@ -12247,12 +12469,19 @@ "integrity": "sha1-07poTYXM1SBUlj0BrWqzbWYtsaU=", "requires": { "xss-filters": "1.2.6" + }, + "dependencies": { + "xss-filters": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.6.tgz", + "integrity": "sha512-uqgwZRpVJCDfHsRX9lDrkPyCitQYzPklmLSbajJncATZKAUd1tF1x9y2VyPNFMv8SsSWed80xorSS5qGpw3WiA==" + } } }, "xss-filters": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.6.tgz", - "integrity": "sha1-aLOQicsd/4udvIiUhIObL1B/XFU=" + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.7.tgz", + "integrity": "sha512-KzcmYT/f+YzcYrYRqw6mXxd25BEZCxBQnf+uXTopQDIhrmiaLwO+f+yLsIvvNlPhYvgff8g3igqrBxYh9k8NbQ==" }, "y18n": { "version": "5.0.8", @@ -12263,8 +12492,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yargs": { "version": "17.6.2", diff --git a/package.json b/package.json index c4f8439..3405918 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulpito", - "version": "2.0.1", + "version": "2.1.0", "description": "API and APP to help organize travels from different places to one destination, and find cheapest weekends to a given destination", "main": "server.js", "scripts": { @@ -8,8 +8,8 @@ "start-dev:build": "tsc && npm run copy-static-files && tsc -w", "start-dev:run": "nodemon --inspect --ext ts,js,pug,json,css build/server.js", "start-dev": "concurrently npm:start-dev:*", - "test": "jest --verbose --watchAll", - "test-prod": "jest", + "test": "jest --verbose --watchAll --runInBand", + "test-prod": "jest --runInBand", "cover": "jest --coverage", "copy-static-files": "cp -v -R src/datasets build/ && cp -v src/views/*.pug build/views/", "postinstall": "tsc && npm run copy-static-files" @@ -28,21 +28,39 @@ "express-rate-limit": "^6.4.0", "helmet": "^5.0.2", "hpp": "^0.2.3", - "jsonwebtoken": "^8.5.1", + "jsonwebtoken": "^9.0.0", + "lodash.clonedeep": "^4.5.0", + "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", "morgan": "^1.10.0", "nodemailer": "^6.7.5", "pug": "^3.0.2", + "save-dev": "^0.0.1-security", "utf8": "^3.0.0", "validator": "^13.7.0", - "xss-clean": "^0.1.1" + "xss-clean": "^0.1.1", + "xss-filters": "^1.2.7" }, "devDependencies": { "@faker-js/faker": "^7.2.0", + "@types/bcryptjs": "^2.4.2", "@types/express": "^4.17.17", + "@types/express-serve-static-core": "^4.17.33", + "@types/hpp": "^0.2.2", "@types/jest": "^29.4.0", + "@types/jsonwebtoken": "^9.0.1", + "@types/lodash": "^4.14.191", + "@types/lodash.clonedeep": "^4.5.7", + "@types/lodash.groupby": "^4.6.7", + "@types/luxon": "^3.2.0", + "@types/morgan": "^1.9.4", "@types/node": "^18.13.0", + "@types/nodemailer": "^6.4.7", + "@types/supertest": "^2.0.12", + "@types/utf8": "^3.0.1", + "@types/validator": "^13.7.12", + "@types/xss-filters": "^0.0.27", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", "concurrently": "^7.6.0", diff --git a/src/airports/airportHelper.ts b/src/airports/airportHelper.ts new file mode 100644 index 0000000..3440333 --- /dev/null +++ b/src/airports/airportHelper.ts @@ -0,0 +1,53 @@ +import utils from '../utils/utils'; +import { Airport } from './airportModel'; + +export const airportContainsQuerySearch = ( + airport: Airport, + str: string +): boolean => { + return ( + (airport.municipality && includesString(airport.municipality, str)) || + (airport.name && includesString(airport.name, str)) || + (airport.iata_code && includesString(airport.iata_code, str)) + // || + // (airport.country && airport.country.toLowerCase().includes(strToLowerCase)) + ); +}; + +export const airportStartsWithQuerySearch = ( + airport: Airport, + str: string +): boolean => { + return ( + (airport.municipality && startsWith(airport.municipality, str)) || + (airport.name && startsWith(airport.name, str)) || + (airport.iata_code && startsWith(airport.iata_code, str)) + // || + // (airport.country && + // airport.country.toLowerCase().startsWith(strToLowerCase)) + ); +}; + +export const reencodeAirport = (airport: Airport): Airport => { + if (!airport) return null; + return { + ...airport, + municipality: airport.municipality + ? utils.reencodeString(airport.municipality) + : null, + name: airport.name ? utils.reencodeString(airport.name) : null, + }; +}; + +const includesString = (property: string, str: string): boolean => { + const strToLowerCase = str.toLowerCase(); + return utils.normalizeString(property).toLowerCase().includes(strToLowerCase); +}; + +const startsWith = (property: string, str: string): boolean => { + const strToLowerCase = str.toLowerCase(); + return utils + .normalizeString(property) + .toLowerCase() + .startsWith(strToLowerCase); +}; diff --git a/src/airports/airportHelper.unit.test.ts b/src/airports/airportHelper.unit.test.ts new file mode 100644 index 0000000..4f0e74c --- /dev/null +++ b/src/airports/airportHelper.unit.test.ts @@ -0,0 +1,63 @@ +import { + airportContainsQuerySearch, + airportStartsWithQuerySearch, +} from './airportHelper'; +import { Airport } from './airportModel'; + +describe('AirportHelper', () => { + describe('airportContainsQuerySearch', () => { + test('should return true if any of airport municipality, name or iata_code contains query search', () => { + const airport: Airport = { + iata_code: 'CDG', + iso_country: 'FR', + municipality: 'Paris', + name: 'Charles de Gaulle International Airport', + type: 'large_airport', + }; + + const querySearch = 'PAR'; + expect(airportContainsQuerySearch(airport, querySearch)).toBe(true); + }); + + test('should return false if none of airport municipality, name or iata_code does contains query search', () => { + const airport: Airport = { + iata_code: 'CDG', + iso_country: 'FR', + municipality: 'Roissy en Brie', + name: 'Charles de Gaulle International Airport', + type: 'large_airport', + }; + + const querySearch = 'PAR'; + expect(airportContainsQuerySearch(airport, querySearch)).toBe(false); + }); + }); + + describe('airportStartsWithQuerySearch', () => { + test('should return true if any of airport municipality, name or iata_code starts with query search', () => { + const airport: Airport = { + iata_code: 'CDG', + iso_country: 'FR', + municipality: 'Paris', + name: 'Charles de Gaulle International Airport', + type: 'large_airport', + }; + + const querySearch = 'PAR'; + expect(airportStartsWithQuerySearch(airport, querySearch)).toBe(true); + }); + + test('should return false if none of airport municipality, name or iata_code starts with query search', () => { + const airport: Airport = { + iata_code: 'CDG', + iso_country: 'FR', + municipality: 'Roissy sur Paris', + name: 'Charles de Gaulle Paris International Airport', + type: 'large_airport', + }; + + const querySearch = 'PAR'; + expect(airportStartsWithQuerySearch(airport, querySearch)).toBe(false); + }); + }); +}); diff --git a/src/airports/airportModel.ts b/src/airports/airportModel.ts new file mode 100644 index 0000000..67d445e --- /dev/null +++ b/src/airports/airportModel.ts @@ -0,0 +1,27 @@ +import fs from 'fs'; +import path from 'path'; + +export type Airport = { + country?: string; + iata_code: string; + iso_country: string; + municipality: string; + name: string; + type: string; +}; + +const parsedAirports = JSON.parse( + fs.readFileSync( + path.join(__dirname, '../datasets/airport-codes.json'), + 'utf-8' + ) +); + +const filterAirportFields = (airport: Partial) => { + const { iata_code, iso_country, municipality, name, type } = airport; + return { iata_code, iso_country, municipality, name, type }; +}; + +export const airports: Airport[] = parsedAirports.map( + filterAirportFields +) as Airport[]; diff --git a/src/airports/airportRepository.ts b/src/airports/airportRepository.ts new file mode 100644 index 0000000..53832d5 --- /dev/null +++ b/src/airports/airportRepository.ts @@ -0,0 +1,33 @@ +import { Airport, airports } from './airportModel'; +import { countries } from '../countries/countryService'; + +export class AirportRepository { + static all = (): Airport[] => { + return ( + airports + .filter((airport) => + ['medium_airport', 'large_airport'].includes(airport.type) + ) + .filter((airport) => airport.iata_code) + //.map(decodeAirport) + .map((airport) => { + return { + ...airport, + country: countries.get(airport.iso_country), + }; + }) + ); + }; + + static allLarge = (): Airport[] => { + return AirportRepository.all().filter( + (airport) => airport.type === `large_airport` + ); + }; + + static allMedium = (): Airport[] => { + return AirportRepository.all().filter( + (airport) => airport.type === `medium_airport` + ); + }; +} diff --git a/src/airports/airportService.ts b/src/airports/airportService.ts index 529a8b7..35d778b 100644 --- a/src/airports/airportService.ts +++ b/src/airports/airportService.ts @@ -1,94 +1,10 @@ -/* eslint-disable no-unused-vars */ -import { countries } from './countryService'; -import fs from 'fs'; -import path from 'path'; - -import utils from '../utils/utils'; - -const airportCodes = JSON.parse( - fs.readFileSync( - path.join(__dirname, '../datasets/airport-codes.json'), - 'utf-8' - ) -); - -const airports = airportCodes - .filter((airport) => - ['medium_airport', 'large_airport'].includes(airport.type) - ) - .filter((airport) => airport.iata_code) - //.map(decodeAirport) - .map((airport) => { - return { - ...airport, - country: countries.get(airport.iso_country), - }; - }); - -const largeAirports = airports.filter( - (airport) => airport.type === `large_airport` -); - -const mediumAirports = airports.filter( - (airport) => airport.type === `medium_airport` -); - -const reencodeAirport = (airport) => { - if (!airport) return null; - return { - ...airport, - municipality: airport.municipality - ? utils.reencodeString(airport.municipality) - : null, - name: airport.name ? utils.reencodeString(airport.name) : null, - }; -}; - -const airportContainsQuerySearch = (airport, str) => { - const strToLowerCase = str.toLowerCase(); - return ( - (airport.municipality && - utils - .normalizeString(airport.municipality) - .toLowerCase() - .includes(strToLowerCase)) || - (airport.name && - utils - .normalizeString(airport.name) - .toLowerCase() - .includes(strToLowerCase)) || - (airport.iata_code && - airport.iata_code.toLowerCase().includes(strToLowerCase)) - // || - // (airport.country && airport.country.toLowerCase().includes(strToLowerCase)) - ); -}; - -const airportStartsWithQuerySearch = (airport, str) => { - const strToLowerCase = str.toLowerCase(); - return ( - (airport.municipality && - utils - .normalizeString(airport.municipality) - .toLowerCase() - .startsWith(strToLowerCase)) || - (airport.name && - utils - .normalizeString(airport.name) - .toLowerCase() - .startsWith(strToLowerCase)) || - (airport.iata_code && - airport.iata_code.toLowerCase().startsWith(strToLowerCase)) - // || - // (airport.country && - // airport.country.toLowerCase().startsWith(strToLowerCase)) - ); -}; - -const filterAirportFields = (airport) => { - const { iata_code, iso_country, municipality, name, type } = airport; - return { iata_code, iso_country, municipality, name, type }; -}; +import { IataCode } from '../common/types'; +import { + airportContainsQuerySearch, + airportStartsWithQuerySearch, + reencodeAirport, +} from './airportHelper'; +import { AirportRepository } from './airportRepository'; /** * Returns the first 10 results @@ -102,19 +18,19 @@ export const searchByString = (searchStr: string) => { const str = searchStr.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); // then get the BIG airports starting with the query string - const largeStartsWith = largeAirports.filter((airport) => + const largeStartsWith = AirportRepository.allLarge().filter((airport) => airportStartsWithQuerySearch(airport, str) ); // then get the MEDIUM airports starting with the query string - const mediumStartsWith = mediumAirports.filter((airport) => + const mediumStartsWith = AirportRepository.allMedium().filter((airport) => airportStartsWithQuerySearch(airport, str) ); // then get the BIG airports that have the query string in their info - const largeContains = largeAirports.filter((airport) => + const largeContains = AirportRepository.allLarge().filter((airport) => airportContainsQuerySearch(airport, str) ); // and finally the MEDIUM airports that have the query string in their info - const mediumContains = mediumAirports.filter((airport) => + const mediumContains = AirportRepository.allMedium().filter((airport) => airportContainsQuerySearch(airport, str) ); @@ -132,7 +48,7 @@ export const searchByString = (searchStr: string) => { // for performance reasons, we convert to a Map to be able to map a iata_code to the corresponding airport. Which is much faster than doing a map and a find ... const airportsMap = new Map( - airports.map((airport) => [airport.iata_code, airport]) + AirportRepository.all().map((airport) => [airport.iata_code, airport]) ); const uniqueAirports = uniqueIataCodes.map((iata_code) => @@ -141,16 +57,18 @@ export const searchByString = (searchStr: string) => { // finally filter out some unnecessary fields (like continent, ...) and reencode special characters for display - return uniqueAirports - .slice(0, 10) - .map(filterAirportFields) - .map(reencodeAirport); + return ( + uniqueAirports + .slice(0, 10) + // .map(filterAirportFields) + .map(reencodeAirport) + ); }; export const findByIataCode = (iataCode: string) => { if (!iataCode) return null; - const airport = airports.find( + const airport = AirportRepository.all().find( (airport) => airport.iata_code && airport.iata_code.toLowerCase() === iataCode.toLowerCase() @@ -165,7 +83,7 @@ export const findByIataCode = (iataCode: string) => { * @param {*} iataCodes city iata codes chosen by the user * @returns array with the airport descrptions for each iata code */ -export const fillAirportDescriptions = (iataCodes) => { +export const fillAirportDescriptions = (iataCodes: IataCode[]) => { return iataCodes.map((iataCode) => { const airportInfo = findByIataCode(iataCode); return `${airportInfo.municipality} - ${airportInfo.name} (${airportInfo.iata_code}) - ${airportInfo.country}`; diff --git a/src/airports/airportService.test.ts b/src/airports/airportService.unit.test.ts similarity index 89% rename from src/airports/airportService.test.ts rename to src/airports/airportService.unit.test.ts index 8446bf2..b975063 100644 --- a/src/airports/airportService.test.ts +++ b/src/airports/airportService.unit.test.ts @@ -6,6 +6,10 @@ import { describe('AirportService', function () { describe('searchByString', function () { + /* FIXME: all these tests depend on the airport-code.json file content. + They will fail if airport-codes.json no longer have Paris for example + This actually are integration tests, since tomorrow airport-codes.json could become a database or something. + */ test('should be able to retrieve airports by any string', function () { const airports = searchByString('paris'); expect(airports.length).toBeGreaterThan(0); diff --git a/src/app.ts b/src/app.ts index e0c55cb..0970a10 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,7 @@ import morgan from 'morgan'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import mongoSanitize from 'express-mongo-sanitize'; -import xss from 'xss-clean'; +import xss from './utils/xss'; import hpp from 'hpp'; import AppError from './utils/appError'; @@ -13,6 +13,7 @@ import { router as destinationsRouter } from './destinations/destinationsRoutes' import { router as userRouter } from './user/userRoutes'; import { router as airportRouter } from './airports/airportRoutes'; import { router as viewRouter } from './views/viewRoutes'; +import { NextFunction, Request, Response } from 'express-serve-static-core'; const app = express(); // better to use early in the middleware. @@ -74,11 +75,12 @@ app.all('*', (req, res, next) => { }); // global error handler -// eslint-disable-next-line @typescript-eslint/no-unused-vars -app.use((err, _req, res, _next) => { - err.statusCode = err.statusCode || 500; - err.status = err.status || 'error'; - +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars +app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { + if (!(err instanceof AppError)) { + err.statusCode = 500; + err.status = 'error'; + } if (err.name === 'JsonWebTokenError') err = handleJWTError(); if (err.name === 'TokenExpiredError') err = handleTokenExpiredError(); diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts new file mode 100644 index 0000000..d8244ea --- /dev/null +++ b/src/common/interfaces.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Query, Send, Response, Request } from 'express-serve-static-core'; +import { APISuccessAnswer, FilterParams } from './types'; + +export interface TypedRequestQueryWithFilter + extends Request { + filter?: K; + query: T; +} + +export interface APISuccessResponse + extends Response { + json: Send; +} diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..5750d22 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,206 @@ +/** + * Itinerary represents a full travel, with : + * - its outbound (oneway) and inbound (return) routes + * - its connections + */ +export type Itinerary = { + flyFrom: IataCode; + flyTo: IataCode; + cityFrom: string; + cityCodeFrom: IataCode; + cityTo: string; + cityCodeTo: IataCode; + countryTo: { + code: string; // 'PT' + name: string; // 'Portugal' + }; + distance: number; + duration: { departure: number; return: number; total: number }; + fare: { adults: number; children: number; infants: number }; + price: number; + // route: Route[]; + onewayRoute: Route; + returnRoute?: Route; + // route uses, in common.pug + // - [oneway|return].connections + // - [oneway|return].local_departure + // - [oneway|return].local_arrival + // - [oneway|return].duration + deep_link: URL; + // local_arrival: ISODate; + // utc_arrival: ISODate; + // local_departure: ISODate; + // utc_departure: ISODate; +}; + +/** + * DestinationWithItineraries represents several full travel options (from several origins). For each given destination we have + * - the city it goes to + * - the total price + * - the total distance + * - its several itineraries (one for each origin) + */ +export type DestinationWithItineraries = { + cityTo: string; + itineraries: Itinerary[]; + countryTo: string; + cityCodeTo: string; + price: number; + distance: number; + // totalDurationDepartureInMinutes: number; + // totalDurationReturnInMinutes: number; +}; + +/** + * Route represents one or several flights from Point A to Point B + */ +export type Route = { + connections: string[]; + local_arrival: ISODate; + utc_arrival: ISODate; + local_departure: ISODate; + utc_departure: ISODate; + duration: string; // hh'h'mm +}; + +export type KiwiRoute = { + flyFrom: IataCode; + flyTo: IataCode; + cityFrom: string; + cityCodeFrom: IataCode; + cityTo: string; + cityCodeTo: IataCode; + return: number; + local_arrival: ISODate; + utc_arrival: ISODate; + local_departure: ISODate; + utc_departure: ISODate; +}; + +export type KiwiItinerary = { + flyFrom: IataCode; + flyTo: IataCode; + cityFrom: string; + cityCodeFrom: IataCode; + cityTo: string; + cityCodeTo: IataCode; + countryTo: { + code: string; // 'PT' + name: string; // 'Portugal' + }; + distance: number; + duration: { departure: number; return: number; total: number }; + fare: { adults: number; children: number; infants: number }; + price: number; + route: KiwiRoute[]; + deep_link: URL; + // local_arrival: ISODate; + // utc_arrival: ISODate; + // local_departure: ISODate; + // utc_departure: ISODate; +}; + +export type IataCode = string; // 3 letters +export type DateDDMMYYYY = string; // string date with format DD/MM/YYYY like "29/01/2023" +type ISODate = string; // string date with iso format like "2023-12-17T09:30:00.000Z" +type URL = string; // for urls + +export enum WeekendLengthEnum { + LONG = 'long', + SHORT = 'short', +} + +export type RegularFlightsParams = { + origin: string; + departureDate: string; + returnDate: string; + adults?: string; + children?: string; + infants?: string; +} & QueryParams; + +export type WeekendFlightsParams = { + origin: string; + destination: string; + departureDateFrom: string; + departureDateTo: string; + adults?: string; + children?: string; + infants?: string; + weekendLength?: WeekendLengthEnum; +} & QueryParams; + +export type QueryParams = { + [key: string]: string; // necessary to dynamically check properties in validator. + sort?: string; + limit?: string; + page?: string; + maxConnections?: string; + priceFrom?: string; + priceTo?: string; +}; + +export type FilterParams = { + sort: string; + limit: number; + page: number; + maxConnections?: number; + priceFrom?: number; + priceTo?: number; +}; + +export type APISuccessAnswer = { + status: 'success'; + totalResults: number; + shownResults: number; + data: object[]; +}; + +export type BaseParamModel = { + required: boolean; + typeCheck: (str: string) => boolean; + errorMsg: string; +}; +export type ParamModel = BaseParamModel & { name: string }; + +export enum DayOfWeek { + SUNDAY = 0, + MONDAY = 1, + TUESDAY = 2, + WEDNESDAY = 3, + THURSDAY = 4, + FRIDAY = 5, + SATURDAY = 6, +} + +export type KiwiBaseAPIParams = { + fly_from: IataCode; + dateFrom: DateDDMMYYYY; + dateTo: DateDDMMYYYY; + adults: number; + children: number; + infants: number; + max_stopovers?: number; + partner_market?: string; + lang?: string; + limit?: number; + flight_type?: 'round' | 'oneway'; +}; + +export type KiwiAPIWeekendParams = { + fly_to: IataCode; + + fly_days?: DayOfWeek[]; + ret_fly_days?: DayOfWeek[]; + nights_in_dst_from?: number; + nights_in_dst_to?: number; +} & KiwiBaseAPIParams; + +export type KiwiAPIAllDaysParams = { + fly_to: 'anywhere'; + returnFrom?: DateDDMMYYYY; + returnTo?: DateDDMMYYYY; + ret_from_diff_airport?: number; + ret_to_diff_airport?: number; + one_for_city?: number; +} & KiwiBaseAPIParams; diff --git a/src/config.ts b/src/config.ts index f9865ed..0a1b854 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,9 @@ const RESULTS_SEARCH_LIMIT = 20; const DEFAULT_SORT_FIELD = 'price'; +const DEFAULT_FIRST_PAGE_OF_RESULT = 1; -export { RESULTS_SEARCH_LIMIT, DEFAULT_SORT_FIELD }; +export { + DEFAULT_FIRST_PAGE_OF_RESULT, + RESULTS_SEARCH_LIMIT, + DEFAULT_SORT_FIELD, +}; diff --git a/src/airports/countryService.ts b/src/countries/countryService.ts similarity index 100% rename from src/airports/countryService.ts rename to src/countries/countryService.ts diff --git a/src/data/flightService.test.ts b/src/data/flightService.integration.test.ts similarity index 65% rename from src/data/flightService.test.ts rename to src/data/flightService.integration.test.ts index 880576e..f29903e 100644 --- a/src/data/flightService.test.ts +++ b/src/data/flightService.integration.test.ts @@ -7,6 +7,7 @@ import { FLIGHT_API_PARAMS_FIXTURE_WEEKEND_NON_EXISTING_ORIGIN, FLIGHT_API_PARAMS_FIXTURE_WEEKEND, } from '../utils/fixtures'; +import { WeekendLengthEnum } from '../common/types'; const maybe = process.env.SKIP_ASYNC_TESTS ? describe.skip : describe; // skip the async tests using Kiwi real URL, if npm test is called like this : @@ -24,33 +25,6 @@ maybe('Flight Service - Integration with KIWI API', function () { expect(flights[0].flyFrom).toBe(FLIGHT_API_PARAMS_FIXTURE.origin); }); - test('should throw a 400 error when empty params for KIWI service', async function () { - // try { - // await flightService.getFlights({}); - // } catch (e) { - // expect(e.message).toMatch(/400/); - // } - expect.assertions(1); - await expect(flightService.getFlights({})).rejects.toMatchObject({ - message: expect.stringMatching(/400/), - }); - }); - - test('should throw a 400 error when missing params for KIWI service', async function () { - const { origin } = FLIGHT_API_PARAMS_FIXTURE; - - // try { - // await flightService.getFlights({ fly_from }); - // } catch (e) { - // expect(e.message).toMatch(/400/); - // } - - expect.assertions(1); - await expect(flightService.getFlights({ origin })).rejects.toMatchObject({ - message: expect.stringMatching(/400/), - }); - }); - test('should throw a 422 error when non-existing origin for KIWI service', async function () { // try { // await flightService.getFlights( @@ -81,10 +55,10 @@ maybe('Flight Service - Integration with KIWI API', function () { test('should use particular parameters if weekend length is long', async () => { const spy = jest.spyOn(axios, 'get').mockImplementation(jest.fn()); - const prepareSpy = jest.spyOn(helper, 'prepareAxiosParams'); + const prepareSpy = jest.spyOn(helper, 'prepareWeekendParamsForAxios'); await flightService.getWeekendFlights({ ...FLIGHT_API_PARAMS_FIXTURE_WEEKEND, - weekendLength: 'long', + weekendLength: WeekendLengthEnum.LONG, }); expect(prepareSpy).toHaveBeenCalledWith( @@ -102,10 +76,10 @@ maybe('Flight Service - Integration with KIWI API', function () { test('should use particular parameters if weekend length is short', async () => { const spy = jest.spyOn(axios, 'get').mockImplementation(jest.fn()); - const prepareSpy = jest.spyOn(helper, 'prepareAxiosParams'); + const prepareSpy = jest.spyOn(helper, 'prepareWeekendParamsForAxios'); await flightService.getWeekendFlights({ ...FLIGHT_API_PARAMS_FIXTURE_WEEKEND, - weekendLength: 'short', + weekendLength: WeekendLengthEnum.SHORT, }); expect(prepareSpy).toHaveBeenCalledWith( @@ -121,32 +95,6 @@ maybe('Flight Service - Integration with KIWI API', function () { spy.mockRestore(); }); - test('should throw a 400 error when empty params for KIWI service', async function () { - // try { - // await flightService.getWeekendFlights({}); - // } catch (e) { - // expect(e.message).toMatch(/400/); - // } - await expect(flightService.getWeekendFlights({})).rejects.toMatchObject({ - message: expect.stringMatching(/400/), - }); - }); - - test('should throw a 400 error when missing params for KIWI service', async function () { - const fly_from = ''; - - // try { - // await flightService.getWeekendFlights({ fly_from }); - // } catch (e) { - // expect(e.message).toMatch(/400/); - // } - await expect( - flightService.getWeekendFlights({ fly_from }) - ).rejects.toMatchObject({ - message: expect.stringMatching(/400/), - }); - }); - test('should throw a 422 error when non-existing origin for KIWI service', async function () { // try { // await flightService.getWeekendFlights( diff --git a/src/data/flightService.ts b/src/data/flightService.ts index e98dd77..b6f1309 100644 --- a/src/data/flightService.ts +++ b/src/data/flightService.ts @@ -1,59 +1,144 @@ import axios from 'axios'; import helper from '../utils/apiHelper'; import { setupCache } from 'axios-cache-interceptor'; +import { + DayOfWeek, + Itinerary, + KiwiAPIAllDaysParams, + KiwiAPIWeekendParams, + KiwiBaseAPIParams, + KiwiItinerary, + RegularFlightsParams, + WeekendFlightsParams, + WeekendLengthEnum, +} from '../common/types'; + +// type DefaultKiwiAPIParams = { +// max_stopovers: 2; +// partner_market: 'fr'; +// lang: 'fr'; +// limit: 1000; +// flight_type: 'round' | 'oneway'; +// ret_from_diff_airport?: 0 | 1; +// ret_to_diff_airport?: 0 | 1; +// one_for_city: 1; +// atime_from?: string; +// atime_to?: string; +// ret_dtime_from?: string; +// ret_dtime_to?: string; +// } + +const DEFAULT_KIWI_API_PARAMS: Partial = { + max_stopovers: 2, + partner_market: 'fr', + lang: 'fr', + limit: 1000, + flight_type: 'round', +}; +const DEFAULT_ADULTS_PARAM = 1; +const DEFAULT_CHILDREN_PARAM = 0; +const DEFAULT_INFANTS_PARAM = 0; setupCache(axios, { ttl: 1000 * 60 * 15 }); //15 minutes +// FIXME: better handle errors +const getFlights = async ( + params: RegularFlightsParams +): Promise => { + try { + const axiosParams: KiwiAPIAllDaysParams = { + ...DEFAULT_KIWI_API_PARAMS, + fly_to: 'anywhere', + fly_from: params.origin, + dateFrom: params.departureDate, + dateTo: params.departureDate, + returnFrom: params.returnDate, + returnTo: params.returnDate, + adults: +(params.adults ?? DEFAULT_ADULTS_PARAM), + children: +(params.children ?? DEFAULT_CHILDREN_PARAM), + infants: +(params.infants ?? DEFAULT_INFANTS_PARAM), + ret_from_diff_airport: 0, + ret_to_diff_airport: 0, + one_for_city: 1, + }; + // atime_from: '10:00', + // atime_to: '22:00', + // ret_dtime_from: '15:00', + // ret_dtime_to: '21:00', + if (!process.env.KIWI_URL || !process.env.KIWI_API_KEY) + throw new Error('Missing KIWI_URL or KIWI_API_KEY environment variables'); + const response = await axios.get(process.env.KIWI_URL, { + headers: { + apikey: process.env.KIWI_API_KEY, + }, + params: axiosParams, + }); + + if (response && response.data) { + const kiwiItineraries: KiwiItinerary[] = response.data.data; + return kiwiItineraries.map(helper.convertKiwiItineraryToItinerary); + } else { + return []; + } + } catch (err) { + console.error(err.message); + // console.error(err.response.data.error); + // console.error(err.response.request.path); + + throw err; + } +}; + // FIXME: added 'any' to allow compiler -const getWeekendFlights = async (params) => { +const getWeekendFlights = async ( + params: WeekendFlightsParams +): Promise => { // var flyingDaysParams = new URLSearchParams(); // flyingDaysParams.append('fly_days', 4); // flyingDaysParams.append('fly_days', 5); // flyingDaysParams.append('fly_days', 6); // FIXME: added 'any' to allow compiler, otherwise it fails. Please create a type or interface. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let axiosParams: any = { - max_stopovers: 2, - partner_market: 'fr', - lang: 'fr', - limit: 1000, - flight_type: 'round', - - // atime_from: '10:00', - // atime_to: '22:00', - // ret_dtime_from: '15:00', - // ret_dtime_to: '21:00', + let axiosParams: KiwiAPIWeekendParams = { + ...DEFAULT_KIWI_API_PARAMS, fly_from: params.origin, fly_to: params.destination, dateFrom: params.departureDateFrom, dateTo: params.departureDateTo, - adults: params.adults, - children: params.children, - infants: params.infants, + adults: +(params.adults ?? DEFAULT_ADULTS_PARAM), + children: +(params.children ?? DEFAULT_CHILDREN_PARAM), + infants: +(params.infants ?? DEFAULT_INFANTS_PARAM), }; - if (params.weekendLength === 'long') { + if (params.weekendLength === WeekendLengthEnum.LONG) { axiosParams = { ...axiosParams, - fly_days: [4, 5, 6], - ret_fly_days: [0, 1, 2], + + fly_days: [DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY], + ret_fly_days: [DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY], nights_in_dst_from: 3, nights_in_dst_to: 4, }; } - if (!params.weekendLength || params.weekendLength === 'short') { + if ( + !params.weekendLength || + params.weekendLength === WeekendLengthEnum.SHORT + ) { axiosParams = { ...axiosParams, - fly_days: [5, 6], - ret_fly_days: [0, 1], + fly_days: [DayOfWeek.FRIDAY, DayOfWeek.SATURDAY], + ret_fly_days: [DayOfWeek.SUNDAY, DayOfWeek.MONDAY], nights_in_dst_from: 1, nights_in_dst_to: 2, }; } try { - const preparedAxiosParams = helper.prepareAxiosParams(axiosParams); + if (!process.env.KIWI_URL || !process.env.KIWI_API_KEY) + throw new Error('Missing KIWI_URL or KIWI_API_KEY environment variables'); + + const preparedAxiosParams = + helper.prepareWeekendParamsForAxios(axiosParams); const response = await axios.get( `${process.env.KIWI_URL}?${preparedAxiosParams.toString()}`, { @@ -62,54 +147,10 @@ const getWeekendFlights = async (params) => { }, } ); - if (response && response.data) { - return response.data.data; - } else { - return []; - } - } catch (err) { - console.error(err.message); - // console.error(err.response.data.error); - // console.error(err.response.request.path); - - throw err; - } -}; - -// FIXME: better handle errors -const getFlights = async (params) => { - try { - const response = await axios.get(process.env.KIWI_URL, { - headers: { - apikey: process.env.KIWI_API_KEY, - }, - params: { - max_stopovers: 2, - partner_market: 'fr', - lang: 'fr', - limit: 1000, - flight_type: 'round', - ret_from_diff_airport: 0, - ret_to_diff_airport: 0, - one_for_city: 1, - fly_to: 'anywhere', - // atime_from: '10:00', - // atime_to: '22:00', - // ret_dtime_from: '15:00', - // ret_dtime_to: '21:00', - fly_from: params.origin, - dateFrom: params.departureDate, - dateTo: params.departureDate, - returnFrom: params.returnDate, - returnTo: params.returnDate, - adults: params.adults, - children: params.children, - infants: params.infants, - }, - }); if (response && response.data) { - return response.data.data; + const kiwiItineraries: KiwiItinerary[] = response.data.data; + return kiwiItineraries.map(helper.convertKiwiItineraryToItinerary); } else { return []; } @@ -122,7 +163,4 @@ const getFlights = async (params) => { } }; -export = { - getWeekendFlights, - getFlights, -}; +export = { getFlights, getWeekendFlights }; diff --git a/src/destinations/destinationsController.test.ts b/src/destinations/destinationsController.integration.test.ts similarity index 60% rename from src/destinations/destinationsController.test.ts rename to src/destinations/destinationsController.integration.test.ts index 2f29347..241d6a6 100644 --- a/src/destinations/destinationsController.test.ts +++ b/src/destinations/destinationsController.integration.test.ts @@ -5,7 +5,6 @@ import { CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE, CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, COMMON_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, - COMMON_DESTINATION_QUERY_FIXTURE_INCORRECT_ORIGIN_FORMAT, COMMON_DESTINATION_QUERY_FIXTURE, COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD, COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU, @@ -14,31 +13,52 @@ import { } from '../utils/fixtures'; import flightService from '../data/flightService'; import AppError from '../utils/appError'; -// import AppError from '../utils/appError'; + +import { Request, NextFunction, Response } from 'express-serve-static-core'; +import { + APISuccessResponse, + TypedRequestQueryWithFilter, +} from '../common/interfaces'; +import { + Itinerary, + RegularFlightsParams, + WeekendFlightsParams, +} from '../common/types'; + +import helper from '../utils/apiHelper'; // FIXME: should be improved or at least checked. Maybe need to refactor, add or remove some tests. I want to move forward and add some e2e tests so I won't spend time on this at the moment, but I could do it later. describe('Destinations Controller', function () { describe('getCheapestDestinations', function () { describe('success cases', function () { - let req, res, next; - let getFlightsSpy; + let req: TypedRequestQueryWithFilter, + res: Partial & { data: Itinerary[] }, + next: NextFunction; + // FIXME: is getFlightsSpy necessary? we need a mock, not a spy ... not sure we need implementation details + let getFlightsSpy: jest.SpyInstance; beforeEach(() => { getFlightsSpy = jest .spyOn(flightService, 'getFlights') - .mockResolvedValue(CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE); + .mockResolvedValue( + CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE.map( + helper.convertKiwiItineraryToItinerary + ) + ); req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE, - }; + } as TypedRequestQueryWithFilter; res = { - status: jest.fn().mockImplementation(function () { + status: jest.fn().mockImplementation(function (code) { + console.log('status method MOCK'); + this.statusCode = code; return this; }), json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); @@ -47,26 +67,38 @@ describe('Destinations Controller', function () { }); test('should use flightService', async function () { - await destinationsController.getCheapestDestinations(req, res, next); + await destinationsController.getCheapestDestinations( + req, + res as Response, + next + ); // check that getFlights has been called expect(flightService.getFlights).toHaveBeenCalled(); }); test('should search for one adult if nothing specified', async function () { - await destinationsController.getCheapestDestinations(req, res, next); + await destinationsController.getCheapestDestinations( + req, + res as Response, + next + ); expect(flightService.getFlights).toHaveBeenCalledWith( expect.objectContaining({ - adults: 1, - children: 0, - infants: 0, + adults: '1', + children: '0', + infants: '0', }) ); }); test('should return success if all good', async function () { - await destinationsController.getCheapestDestinations(req, res, next); + await destinationsController.getCheapestDestinations( + req, + res as Response, + next + ); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data)).toBe(true); @@ -83,7 +115,9 @@ describe('Destinations Controller', function () { }); }); describe('error cases', function () { - let res, next; + let req: TypedRequestQueryWithFilter, + res: Partial & { data: Itinerary[] }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -92,50 +126,21 @@ describe('Destinations Controller', function () { json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); - // TODO: is it necessary? before getCheapestDestination, we have a middleware checking for input params - test('should return error 400 when no input parameters', async function () { - const req = { query: {} }; - - await destinationsController.getCheapestDestinations(req, res, next); - - // check that response is an error - expect(next).toHaveBeenCalledWith(expect.any(AppError)); - - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - statusCode: 400, - message: expect.stringContaining('departure location'), - }) - ); - }); - - test('should return error 400 when missing input parameters', async function () { - const req = { query: { origin: 'CDG' } }; - - await destinationsController.getCheapestDestinations(req, res, next); - - // check that response is an error - expect(next).toHaveBeenCalledWith(expect.any(AppError)); - - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - statusCode: 400, - message: expect.stringContaining('when roundtrip requested'), - }) - ); - }); - test('should return error 400 when unknown origin like PXR', async function () { - const req = { + req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, - }; + } as TypedRequestQueryWithFilter; - await destinationsController.getCheapestDestinations(req, res, next); + await destinationsController.getCheapestDestinations( + req, + res as Response, + next + ); // check that response is an error expect(next).toHaveBeenCalledWith(expect.any(AppError)); @@ -152,20 +157,38 @@ describe('Destinations Controller', function () { describe('getCommonDestinations', function () { describe('success cases', function () { - let req, res, next; + let req: TypedRequestQueryWithFilter, + res: Partial & { data: Itinerary[] }, + next: NextFunction; - let getFlightsSpy; + let getFlightsSpy: jest.SpyInstance; beforeEach(() => { getFlightsSpy = jest .spyOn(flightService, 'getFlights') - .mockResolvedValue(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD) - .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD) - .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD) - .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU); + .mockResolvedValue( + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD.map( + helper.convertKiwiItineraryToItinerary + ) + ) + .mockResolvedValueOnce( + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD.map( + helper.convertKiwiItineraryToItinerary + ) + ) + .mockResolvedValueOnce( + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD.map( + helper.convertKiwiItineraryToItinerary + ) + ) + .mockResolvedValueOnce( + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU.map( + helper.convertKiwiItineraryToItinerary + ) + ); req = { query: COMMON_DESTINATION_QUERY_FIXTURE, - }; + } as TypedRequestQueryWithFilter; res = { status: jest.fn().mockImplementation(function () { @@ -174,7 +197,7 @@ describe('Destinations Controller', function () { json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); @@ -183,35 +206,48 @@ describe('Destinations Controller', function () { }); test('should call flightService the correct number of times', async function () { - await destinationsController.getCommonDestinations(req, res, next); + await destinationsController.getCommonDestinations( + req, + res as Response, + next + ); expect(flightService.getFlights).toHaveBeenCalledTimes( COMMON_DESTINATION_QUERY_FIXTURE.origin.split(',').length ); }); + // TODO: not sure if necessary ... isn't it part of endtoend tests? test('should search for one adult for each origin if nothing specified', async function () { - await destinationsController.getCommonDestinations(req, res, next); + await destinationsController.getCommonDestinations( + req, + res as Response, + next + ); expect(flightService.getFlights).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - adults: 1, - children: 0, - infants: 0, + adults: '1', + children: '0', + infants: '0', }) ); expect(flightService.getFlights).toHaveBeenNthCalledWith( COMMON_DESTINATION_QUERY_FIXTURE.origin.split(',').length - 1, expect.objectContaining({ - adults: 1, - children: 0, - infants: 0, + adults: '1', + children: '0', + infants: '0', }) ); }); test('should return the correct common destinations', async function () { - await destinationsController.getCommonDestinations(req, res, next); + await destinationsController.getCommonDestinations( + req, + res as Response, + next + ); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data)).toBe(true); @@ -219,9 +255,29 @@ describe('Destinations Controller', function () { expect(res.data[0].cityTo).toBe('Ibiza'); expect(res.data).toHaveLength(1); }); + + test('should return empty data when there are no common destinations', async function () { + req.query = { ...COMMON_DESTINATION_QUERY_FIXTURE, origin: 'MAD,MRS' }; + + flightService.getFlights = jest + .fn() + .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD) + .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS); + await destinationsController.getCommonDestinations( + req, + res as Response, + next + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(Array.isArray(res.data)).toBe(true); + expect(res.data).toHaveLength(0); + }); }); describe('error cases', function () { - let res, next; + let req: Partial, + res: Partial & { data: Itinerary[] }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -230,94 +286,49 @@ describe('Destinations Controller', function () { json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); - test('should return error 500 when no input parameters', async function () { - const req = { query: {} }; - await destinationsController.getCommonDestinations(req, res, next); - - expect(next).toHaveBeenCalledWith(expect.any(AppError)); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - statusCode: 500, - }) - ); - }); - - test('should return error 400 when parameters are not comma-separated', async function () { - const req = { - query: COMMON_DESTINATION_QUERY_FIXTURE_INCORRECT_ORIGIN_FORMAT, - }; - await destinationsController.getCommonDestinations(req, res, next); - - expect(next).toHaveBeenCalledWith(expect.any(AppError)); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - statusCode: 400, - message: expect.stringContaining('no locations'), - }) - ); - }); - test('should return error 400 when unknown origin like PXR', async function () { - const req = { - query: COMMON_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, - }; - await destinationsController.getCommonDestinations(req, res, next); - - expect(next).toHaveBeenCalledWith(expect.any(AppError)); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - statusCode: 400, - message: expect.stringContaining('no locations'), - }) + req = { query: COMMON_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN }; + await destinationsController.getCommonDestinations( + req as Request, + res as Response, + next ); - }); - - test('should return error 400 when missing input parameters', async function () { - const req = { query: { origin: 'MAD,BKK,CDG' } }; - await destinationsController.getCommonDestinations(req, res, next); expect(next).toHaveBeenCalledWith(expect.any(AppError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: 400, - message: expect.stringContaining('when roundtrip requested'), + message: expect.stringContaining('no locations'), }) ); }); - - test('should return empty data when there are no common destinations', async function () { - const req = { query: { origin: 'MAD,MRS' } }; - - flightService.getFlights = jest - .fn() - .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD) - .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS); - await destinationsController.getCommonDestinations(req, res, next); - - expect(res.status).toHaveBeenCalledWith(200); - expect(Array.isArray(res.data)).toBe(true); - expect(res.data).toHaveLength(0); - }); }); }); describe('getCheapestWeekend', function () { describe('success cases', function () { - let req, res, next; - let getFlightsSpy; + let req: TypedRequestQueryWithFilter, + res: Partial & { data: Itinerary[] }, + next: NextFunction; + // FIXME: is getFlightsSpy necessary? we need a mock, not a spy ... not sure we need implementation details + let getFlightsSpy: jest.SpyInstance; beforeEach(() => { getFlightsSpy = jest .spyOn(flightService, 'getWeekendFlights') - .mockResolvedValue(CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE); + .mockResolvedValue( + CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE.map( + helper.convertKiwiItineraryToItinerary + ) + ); req = { query: CHEAPEST_WEEKEND_QUERY_FIXTURE, - }; + } as TypedRequestQueryWithFilter; res = { status: jest.fn().mockImplementation(function () { @@ -326,7 +337,7 @@ describe('Destinations Controller', function () { json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); @@ -334,27 +345,12 @@ describe('Destinations Controller', function () { getFlightsSpy.mockRestore(); }); - test('should use flightService', async function () { - await destinationsController.getCheapestWeekend(req, res, next); - - // check that getWeekendFlights has been called - expect(flightService.getWeekendFlights).toHaveBeenCalled(); - }); - - test('should search for one adult if nothing specified', async function () { - await destinationsController.getCheapestWeekend(req, res, next); - - expect(flightService.getWeekendFlights).toHaveBeenCalledWith( - expect.objectContaining({ - adults: 1, - children: 0, - infants: 0, - }) - ); - }); - test('should return success if all good', async function () { - await destinationsController.getCheapestWeekend(req, res, next); + await destinationsController.getCheapestWeekend( + req, + res as Response, + next + ); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data)).toBe(true); @@ -372,9 +368,15 @@ describe('Destinations Controller', function () { // this is an error case for getFlights, but for getFlightsWeekend we add params nights_in_dst_from and nights_in_dest_to which are enough for Kiwi to perform a search, even though there are no departure dates interval (from->to) and destination. test('should return success when only origin is specified, and no possible departure dates and no destination', async function () { - const req = { query: { origin: 'CDG' } }; + const req = { + query: { origin: 'CDG' }, + } as TypedRequestQueryWithFilter; - await destinationsController.getCheapestWeekend(req, res, next); + await destinationsController.getCheapestWeekend( + req, + res as Response, + next + ); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data)).toBe(true); @@ -389,9 +391,28 @@ describe('Destinations Controller', function () { // checking that it has been cleaned expect(res.data[0]).not.toHaveProperty('countryFrom'); }); + + // TODO: not sure if necessary ... isn't it part of endtoend tests? + test('should search for one adult if nothing specified', async function () { + await destinationsController.getCheapestWeekend( + req, + res as Response, + next + ); + + expect(flightService.getWeekendFlights).toHaveBeenCalledWith( + expect.objectContaining({ + adults: '1', + children: '0', + infants: '0', + }) + ); + }); }); describe('error cases', function () { - let res, next; + let req: TypedRequestQueryWithFilter, + res: Partial & { data: Itinerary[] }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -400,34 +421,21 @@ describe('Destinations Controller', function () { json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); - // TODO: is it necessary? before getCheapestDestination, we have a middleware checking for input params - test('should return error 400 when no input parameters', async function () { - const req = { query: {} }; - - await destinationsController.getCheapestWeekend(req, res, next); - - // check that response is an error - expect(next).toHaveBeenCalledWith(expect.any(AppError)); - - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - statusCode: 400, - message: expect.stringContaining('departure location'), - }) - ); - }); - test('should return error 400 when unknown origin like PXR', async function () { - const req = { + req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, - }; + } as TypedRequestQueryWithFilter; - await destinationsController.getCheapestWeekend(req, res, next); + await destinationsController.getCheapestWeekend( + req, + res as Response, + next + ); // check that response is an error expect(next).toHaveBeenCalledWith(expect.any(AppError)); diff --git a/src/destinations/destinationsController.ts b/src/destinations/destinationsController.ts index d42a021..df19e72 100644 --- a/src/destinations/destinationsController.ts +++ b/src/destinations/destinationsController.ts @@ -3,6 +3,13 @@ import { catchAsyncKiwi } from '../utils/catchAsync'; import flightService from '../data/flightService'; import destinationsService from './destinationsService'; import resultsHelper from '../utils/resultsHelper'; +import { + FilterParams, + RegularFlightsParams, + WeekendFlightsParams, +} from '../common/types'; +import { TypedRequestQueryWithFilter } from '../common/interfaces'; +import { APISuccessResponse } from '../common/interfaces'; /** * Find cheapest destinations from this origin. @@ -13,22 +20,28 @@ import resultsHelper from '../utils/resultsHelper'; * @param {*} req * @param {*} res */ -const getCheapestDestinations = catchAsyncKiwi(async (req, res) => { - const params = helper.prepareDefaultAPIParams(req.query); +const getCheapestDestinations = catchAsyncKiwi( + async ( + req: TypedRequestQueryWithFilter, + res: APISuccessResponse + ): Promise => { + const params = helper.prepareDefaultAPIParams( + req.query + ) as RegularFlightsParams; - const flights = await flightService.getFlights(params); + let itineraries = await flightService.getFlights(params); - let itineraries = flights.map(helper.cleanItineraryData); - const totalResults = itineraries.length; - itineraries = resultsHelper.applyFilters(itineraries, req.filter); + const totalResults = itineraries.length; + itineraries = resultsHelper.applyFilters(itineraries, req.filter); - res.status(200).json({ - status: 'success', - totalResults, - shownResults: itineraries.length, - data: itineraries, //itineraries, - }); -}); + res.status(200).json({ + status: 'success', + totalResults, + shownResults: itineraries.length, + data: itineraries, //itineraries, + }); + } +); /** * Find common destinations to several origins. @@ -38,47 +51,62 @@ const getCheapestDestinations = catchAsyncKiwi(async (req, res) => { * @param {*} req * @param {*} res */ -const getCommonDestinations = catchAsyncKiwi(async (req, res) => { - console.info( - 'API - Getting common destinations with these params', - req.query - ); - const allOriginsParams = helper.prepareSeveralOriginAPIParams(req.query); +const getCommonDestinations = catchAsyncKiwi( + async ( + req: TypedRequestQueryWithFilter, + res: APISuccessResponse + ): Promise => { + console.info( + 'API - Getting common destinations with these params', + req.query + ); + const allOriginsParams = helper.prepareSeveralOriginAPIParams(req.query); - // const instance = prepareAxiosRequest(); + // const instance = prepareAxiosRequest(); - const origins = req.query.origin.split(','); + const origins = req.query.origin.split(','); - let commonItineraries = await destinationsService.buildCommonItineraries( - allOriginsParams, - origins - ); - const totalResults = commonItineraries.length; - commonItineraries = resultsHelper.applyFilters(commonItineraries, req.filter); - res.status(200).json({ - status: 'success', - totalResults, - shownResults: commonItineraries.length, - data: commonItineraries, - }); -}); + let destinations = await destinationsService.buildCommonItineraries( + allOriginsParams, + origins + ); + const totalResults = destinations.length; + destinations = resultsHelper.applyFilters(destinations, req.filter); + res.status(200).json({ + status: 'success', + totalResults, + shownResults: destinations.length, + data: destinations, + }); + } +); -const getCheapestWeekend = catchAsyncKiwi(async (req, res) => { - const params = helper.prepareDefaultAPIParams(req.query); +const getCheapestWeekend = catchAsyncKiwi( + async ( + req: TypedRequestQueryWithFilter, + res: APISuccessResponse + ): Promise => { + const params = helper.prepareDefaultAPIParams( + req.query + ) as WeekendFlightsParams; - const flights = await flightService.getWeekendFlights(params); + let itineraries = await flightService.getWeekendFlights(params); - let itineraries = flights.map(helper.cleanItineraryData); - const totalResults = itineraries.length; + const totalResults = itineraries.length; - itineraries = resultsHelper.applyFilters(itineraries, req.filter); + itineraries = resultsHelper.applyFilters(itineraries, req.filter); - res.status(200).json({ - status: 'success', - totalResults, - shownResults: itineraries.length, - data: itineraries, //flights, - }); -}); + res.status(200).json({ + status: 'success', + totalResults, + shownResults: itineraries.length, + data: itineraries, //flights, + }); + } +); -export = { getCheapestDestinations, getCommonDestinations, getCheapestWeekend }; +export = { + getCheapestDestinations, + getCommonDestinations, + getCheapestWeekend, +}; diff --git a/src/destinations/destinationsRoutes.ts b/src/destinations/destinationsRoutes.ts index 09269d4..2710cd8 100644 --- a/src/destinations/destinationsRoutes.ts +++ b/src/destinations/destinationsRoutes.ts @@ -1,7 +1,12 @@ import express from 'express'; import destinationsController from './destinationsController'; -import validatorService from '../common/validatorService'; +import { + validateRequestParamsManyOrigins, + validateRequestParamsOneOrigin, + validateRequestParamsWeekend, + filterParams, +} from '../middleware/validator/validatorService'; export const router = express.Router(); @@ -9,15 +14,15 @@ export const router = express.Router(); router .route('/cheapest') .get( - validatorService.filterParams, - validatorService.validateRequestParamsOneOrigin, + filterParams, + validateRequestParamsOneOrigin, destinationsController.getCheapestDestinations ); router .route('/common') .get( - validatorService.filterParams, - validatorService.validateRequestParamsManyOrigins, + filterParams, + validateRequestParamsManyOrigins, destinationsController.getCommonDestinations ); @@ -25,7 +30,7 @@ router router .route('/cheapestWeekend') .get( - validatorService.filterParams, - validatorService.validateRequestParamsWeekend, + filterParams, + validateRequestParamsWeekend, destinationsController.getCheapestWeekend ); diff --git a/src/destinations/destinationsService.test.ts b/src/destinations/destinationsService.integration.test.ts similarity index 100% rename from src/destinations/destinationsService.test.ts rename to src/destinations/destinationsService.integration.test.ts diff --git a/src/destinations/destinationsService.ts b/src/destinations/destinationsService.ts index acc6df2..2bf305e 100644 --- a/src/destinations/destinationsService.ts +++ b/src/destinations/destinationsService.ts @@ -1,8 +1,11 @@ import flightService from '../data/flightService'; -import groupByToMap from 'core-js-pure/actual/array/group-by-to-map'; import helper from '../utils/apiHelper'; +import { RegularFlightsParams } from '../common/types'; -const buildCommonItineraries = async (allOriginsParams, origins) => { +const buildCommonItineraries = async ( + allOriginsParams: RegularFlightsParams[], + origins: string[] +) => { // create one GET call for each origin const searchDestinations = allOriginsParams.map((params) => flightService.getFlights(params) @@ -19,9 +22,10 @@ const buildCommonItineraries = async (allOriginsParams, origins) => { // group the array by field item.flyTo and extract all possible destinations // Array.groupByToMap is in stage 3 proposal // can be switched to lodash.groupBy (https://lodash.com/docs/4.17.15#groupBy) - const destinations = groupByToMap(itineraries, (item) => { - return item.cityTo; - }); + // const destinations = groupByToMap(itineraries, (item) => { + // return item.cityTo; + // }); + const destinations = helper.groupByDestination(itineraries); // only the destinations that are common to all the origins in that request // i.e. if origins is ['JFK','LON', 'CDG'] and all origins have destination 'Dubai' but only 'JFK' and 'CDG' have destination 'Bangkok', only 'Dubai' will kept @@ -31,12 +35,8 @@ const buildCommonItineraries = async (allOriginsParams, origins) => { ); // build a map with the total number of passengers per origin - const passengersPerOrigin = new Map( - allOriginsParams.map((oneOriginParam) => [ - oneOriginParam.origin, - oneOriginParam.adults + oneOriginParam.children + oneOriginParam.infants, - ]) - ); + const passengersPerOrigin = + helper.getMapPassengersPerOrigin(allOriginsParams); // only keep itineraries that have a destination in the list of common destinations // if an itinerary goes from Madrid to Dublin but doesn't go from Paris to Dublin, we will not keep Dublin @@ -44,15 +44,15 @@ const buildCommonItineraries = async (allOriginsParams, origins) => { filteredDestinationCities.includes(itinerary.cityTo) ); - // remove unnecessary fields - // FIXME: this operation takes now 100-250ms to complete, depending on the number of itineraries to clean - const cleanedItineraries = filteredItineraries.map(helper.cleanItineraryData); - // For each destination, have an array with the flights, total price and total distance and total duration // (preparing for display) // and sort by price const commonItineraries = filteredDestinationCities.map((dest) => - helper.prepareItineraryData(dest, cleanedItineraries, passengersPerOrigin) + helper.prepareDestinationData( + dest, + filteredItineraries, + passengersPerOrigin + ) ); return commonItineraries; }; diff --git a/src/middleware/validator/validatorHelper.ts b/src/middleware/validator/validatorHelper.ts new file mode 100644 index 0000000..838a785 --- /dev/null +++ b/src/middleware/validator/validatorHelper.ts @@ -0,0 +1,49 @@ +import { + DEFAULT_FIRST_PAGE_OF_RESULT, + DEFAULT_SORT_FIELD, + RESULTS_SEARCH_LIMIT, +} from '../../config'; +import { FilterParams, QueryParams } from '../../common/types'; + +export const getFilterParamsFromQueryParams = ( + query: QueryParams +): FilterParams => { + const filter = {} as FilterParams; + if (query.sort) { + filter.sort = query.sort; + delete query.sort; + } else { + filter.sort = DEFAULT_SORT_FIELD; + } + + if (query.limit) { + filter.limit = +query.limit; + delete query.limit; + } else { + filter.limit = RESULTS_SEARCH_LIMIT; + } + + if (query.page) { + filter.page = +query.page; + delete query.page; + } else { + filter.page = DEFAULT_FIRST_PAGE_OF_RESULT; + } + + if (query.maxConnections) { + filter.maxConnections = +query.maxConnections; + delete query.maxConnections; + } + + if (query.priceFrom) { + filter.priceFrom = +query.priceFrom; + delete query.priceFrom; + } + + if (query.priceTo) { + filter.priceTo = +query.priceTo; + delete query.priceTo; + } + + return filter; +}; diff --git a/src/middleware/validator/validatorHelper.unit.test.ts b/src/middleware/validator/validatorHelper.unit.test.ts new file mode 100644 index 0000000..bf68a37 --- /dev/null +++ b/src/middleware/validator/validatorHelper.unit.test.ts @@ -0,0 +1,64 @@ +import { RegularFlightsParams } from '../../common/types'; +import { + DEFAULT_FIRST_PAGE_OF_RESULT, + DEFAULT_SORT_FIELD, + RESULTS_SEARCH_LIMIT, +} from '../../config'; +import { getFilterParamsFromQueryParams } from './validatorHelper'; + +describe('Validator Helper', () => { + describe('getFilterParamsFromQueryParams', () => { + test("should return an object with param 'sort'", function () { + const query: RegularFlightsParams = { + origin: 'MAD', + departureDate: '23032023', + returnDate: '26032023', + sort: 'price', + }; + + const filter = getFilterParamsFromQueryParams(query); + expect(filter.sort).toBe('price'); + }); + + test("should remove param 'sort' from req.query", function () { + const query: RegularFlightsParams = { + origin: 'MAD', + departureDate: '23032023', + returnDate: '26032023', + sort: 'price', + }; + + getFilterParamsFromQueryParams(query); + expect(query.sort).toBeUndefined(); + }); + + test("should return an object with default params 'sort', 'limit' and 'page' even when not present", function () { + const query: RegularFlightsParams = { + origin: 'MAD', + departureDate: '23032023', + returnDate: '26032023', + }; + + const filter = getFilterParamsFromQueryParams(query); + expect(filter.sort).toBe(DEFAULT_SORT_FIELD); + expect(filter.limit).toBe(RESULTS_SEARCH_LIMIT); + expect(filter.page).toBe(DEFAULT_FIRST_PAGE_OF_RESULT); + }); + + test("should return an object with params 'maxConnections', 'priceFrom', 'priceTo' when present", function () { + const query: RegularFlightsParams = { + origin: 'MAD', + departureDate: '23032023', + returnDate: '26032023', + maxConnections: '2', + priceFrom: '32', + priceTo: '56', + }; + + const filter = getFilterParamsFromQueryParams(query); + expect(filter.maxConnections).toBe(2); + expect(filter.priceFrom).toBe(32); + expect(filter.priceTo).toBe(56); + }); + }); +}); diff --git a/src/common/validatorService.test.ts b/src/middleware/validator/validatorService.integration.test.ts similarity index 66% rename from src/common/validatorService.test.ts rename to src/middleware/validator/validatorService.integration.test.ts index dea30f8..4c22c19 100644 --- a/src/common/validatorService.test.ts +++ b/src/middleware/validator/validatorService.integration.test.ts @@ -1,76 +1,22 @@ -import validatorService from './validatorService'; -import { RESULTS_SEARCH_LIMIT, DEFAULT_SORT_FIELD } from '../config'; -import AppError from '../utils/appError'; +import { + validateRequestParamsManyOrigins, + validateRequestParamsOneOrigin, + validateRequestParamsWeekend, +} from './validatorService'; +import AppError from '../../utils/appError'; +import { TypedRequestQueryWithFilter } from '../../common/interfaces'; +import { + Itinerary, + RegularFlightsParams, + WeekendFlightsParams, +} from '../../common/types'; +import { NextFunction, Response } from 'express-serve-static-core'; describe('ValidatorService', function () { - describe('filterParams', function () { - let req, res, next; - beforeEach(() => { - res = { - status: jest.fn().mockImplementation(function () { - // console.log('calling res.status'); - return this; - }), - json: jest.fn().mockImplementation(function (obj) { - // console.log('calling res.json'); - this.data = obj.data; - this.message = obj.message; - }), - data: null, - message: null, - }; - - req = {}; - - next = jest.fn().mockImplementation(function (err) { - console.error(err); - }); - }); - - test("should add param 'sort' to req.filter", function () { - req = { query: { origin: 'MAD', sort: 'price' } }; - - validatorService.filterParams(req, res, next); - expect(req.filter.sort).toBe('price'); - }); - - test("should remove param 'sort' from req.query", function () { - req = { query: { origin: 'MAD', sort: 'price' } }; - - validatorService.filterParams(req, res, next); - expect(req.query.sort).toBeUndefined(); - }); - - test("should not add param 'origin' to req.filter", function () { - req = { query: { origin: 'MAD', sort: 'price' } }; - - validatorService.filterParams(req, res, next); - expect(req.filter.origin).toBeUndefined(); - }); - - test('should add default params to req.filter even when not present', function () { - req = { query: { origin: 'MAD' } }; - - validatorService.filterParams(req, res, next); - expect(req.filter.sort).toBe(DEFAULT_SORT_FIELD); - expect(req.filter.limit).toBe(RESULTS_SEARCH_LIMIT); - expect(req.filter.page).toBe(1); - }); - - test('should add params maxConnections, priceFrom, priceTo to req.filter when present', function () { - req = { - query: { origin: 'MAD', maxConnections: 2, priceFrom: 32, priceTo: 56 }, - }; - - validatorService.filterParams(req, res, next); - expect(req.filter.maxConnections).toBe(2); - expect(req.filter.priceFrom).toBe(32); - expect(req.filter.priceTo).toBe(56); - }); - }); - + // FIXME: all the tests depend on implementation details (like the error msg ...) describe('validate middleware', function () { - let req, res, next; + let res: Partial & { data: Itinerary[]; message: string }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -82,18 +28,16 @@ describe('ValidatorService', function () { this.data = obj.data; this.message = obj.message; }), - data: null, - message: null, + data: [], + message: '', }; - req = {}; - next = jest.fn().mockImplementation(function (err) { console.error(err); }); }); - describe('validateRequestParamsWeekend', function () { + let req: Partial>; test('should call next with no arguments when params are ok', function () { req = { query: { @@ -104,36 +48,50 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); // expect(next).toHaveBeenCalledWith() is always true, whether next is called without any argument or with an error. expect(next).not.toHaveBeenCalledWith(expect.any(AppError)); }); + test('should call next with an AppError when origin param is missing', function () { req = { query: { destination: 'BXL', departureDateFrom: '22/06/2022', departureDateTo: '29/06/2022', - }, + } as WeekendFlightsParams, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), }) ); }); + test('should call next with an AppError when destination param is missing', function () { req = { query: { origin: 'BXL', departureDateFrom: '22/06/2022', departureDateTo: '29/06/2022', - }, + } as WeekendFlightsParams, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)destination/), @@ -146,10 +104,14 @@ describe('ValidatorService', function () { origin: 'BXL', destination: 'MAD', departureDateTo: '29/06/2022', - }, + } as WeekendFlightsParams, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDateFrom/), @@ -162,10 +124,14 @@ describe('ValidatorService', function () { origin: 'BXL', destination: 'MAD', departureDateFrom: '29/06/2022', - }, + } as WeekendFlightsParams, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDateTo/), @@ -182,7 +148,11 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -192,16 +162,21 @@ describe('ValidatorService', function () { }); describe('validateRequestParamsOneOrigin', function () { + let req: Partial>; test('should call next with no arguments when params are ok', function () { req = { query: { origin: 'MAD', departureDate: '22/06/2022', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).not.toHaveBeenCalledWith(expect.any(AppError)); }); test('should call next with an AppError when origin param is missing', function () { @@ -209,10 +184,14 @@ describe('ValidatorService', function () { query: { departureDate: '22/06/2022', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), @@ -225,10 +204,14 @@ describe('ValidatorService', function () { query: { origin: 'BXL', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDate/), @@ -241,10 +224,14 @@ describe('ValidatorService', function () { query: { origin: 'BXL', departureDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)returnDate/), @@ -260,7 +247,11 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -270,6 +261,7 @@ describe('ValidatorService', function () { }); describe('validateRequestParamsManyOrigins', function () { + let req: Partial>; test('should call next with no arguments when params are ok', function () { req = { query: { @@ -280,7 +272,11 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).not.toHaveBeenCalledWith(expect.any(AppError)); }); test('should call next with an AppError when origin param is missing', function () { @@ -288,10 +284,14 @@ describe('ValidatorService', function () { query: { departureDate: '22/06/2022', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), @@ -304,10 +304,14 @@ describe('ValidatorService', function () { query: { origin: 'MAD,BXL', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDate/), @@ -320,10 +324,14 @@ describe('ValidatorService', function () { query: { origin: 'MAD,BXL', departureDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)returnDate/), @@ -339,7 +347,11 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -357,7 +369,11 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)adults/), @@ -375,7 +391,11 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)children/), @@ -393,7 +413,11 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)infants/), @@ -402,7 +426,7 @@ describe('ValidatorService', function () { }); }); - // no need for tests for checkMissingParams or checkWrongTypeParams - // their functionality is covered by testing the different validate middlewares + // FIXME: need for tests for checkMissingParams or checkWrongTypeParams, even if their functionality is covered by testing the different validate middlewares + // indeed when we test the valdiate middlewares we test the error messages, mainly. }); }); diff --git a/src/common/validatorService.ts b/src/middleware/validator/validatorService.ts similarity index 79% rename from src/common/validatorService.ts rename to src/middleware/validator/validatorService.ts index 30847aa..d2f3620 100644 --- a/src/common/validatorService.ts +++ b/src/middleware/validator/validatorService.ts @@ -1,20 +1,21 @@ -import validator from '../utils/validator'; -import { isAlpha, isDate, isNumeric } from 'validator'; -import AppError from '../utils/appError'; -import { RESULTS_SEARCH_LIMIT, DEFAULT_SORT_FIELD } from '../config'; +import validator from '../../utils/validator'; +import validatorJs from 'validator'; +import AppError from '../../utils/appError'; +import { NextFunction, Response } from 'express'; +import { TypedRequestQueryWithFilter } from '../../common/interfaces'; +import { + BaseParamModel, + FilterParams, + ParamModel, + QueryParams, + RegularFlightsParams, + WeekendFlightsParams, +} from '../../common/types'; +import { getFilterParamsFromQueryParams } from './validatorHelper'; -const PARAMS_TO_FILTER = [ - { name: 'sort', default: DEFAULT_SORT_FIELD }, - { name: 'limit', default: RESULTS_SEARCH_LIMIT }, - { name: 'page', default: 1 }, - { name: 'maxConnections' }, - { name: 'priceFrom' }, - { name: 'priceTo' }, -]; - -const ONE_CITYCODE_PARAM_MODEL = { +const ONE_CITYCODE_PARAM_MODEL: BaseParamModel = { required: true, - typeCheck: isAlpha, + typeCheck: validatorJs.isAlpha, errorMsg: 'only airport or airport area codes, for example LON or JFK', // see https://wikitravel.org/en/Metropolitan_Area_Airport_Codes }; const SEVERAL_CITYCODES_PARAM_MODEL = { @@ -24,12 +25,12 @@ const SEVERAL_CITYCODES_PARAM_MODEL = { }; const DATE_PARAM_MODEL = { required: true, - typeCheck: (str) => isDate(str, { format: 'DD/MM/YYYY' }), + typeCheck: (str: string) => validatorJs.isDate(str, { format: 'DD/MM/YYYY' }), errorMsg: `a date of format DD/MM/YYYY, for example 22/06/2022`, }; const ONE_PASSENGER_PARAM_MODEL = { required: false, - typeCheck: isNumeric, + typeCheck: validatorJs.isNumeric, errorMsg: 'a number, for example 2', }; const SEVERAL_PASSENGERS_PARAM_MODEL = { @@ -45,23 +46,13 @@ const SEVERAL_PASSENGERS_PARAM_MODEL = { * @param {*} res * @param {*} next */ -const filterParams = (req, res, next) => { - req.filter = {}; +export const filterParams = ( + req: TypedRequestQueryWithFilter, + res: Response, + next: NextFunction +) => { if (req.query) { - PARAMS_TO_FILTER.forEach((param) => { - if (req.query[param.name]) { - // if param present in the queryString, we add it to req.filter and remove it from req.query - - req.filter[param.name] = req.query[param.name]; - - delete req.query[param.name]; - } else { - // if param not present but he has a default value, we add it to req.filter - if (param.default) { - req.filter[param.name] = param.default; - } - } - }); + req.filter = getFilterParamsFromQueryParams(req.query); } next(); }; @@ -72,8 +63,12 @@ const filterParams = (req, res, next) => { * @param {*} res * @param {*} next */ -const validateRequestParamsWeekend = (req, res, next) => { - const requestModelParams = [ +export const validateRequestParamsWeekend = ( + req: TypedRequestQueryWithFilter, + res: Response, + next: NextFunction +) => { + const requestModelParams: ParamModel[] = [ { name: 'origin', ...ONE_CITYCODE_PARAM_MODEL }, { name: 'destination', @@ -113,9 +108,13 @@ const validateRequestParamsWeekend = (req, res, next) => { * @param {*} res * @param {*} next */ -const validateRequestParamsManyOrigins = (req, res, next) => { +export const validateRequestParamsManyOrigins = ( + req: TypedRequestQueryWithFilter, + res: Response, + next: NextFunction +) => { // FIXME: is this really necessary to be so specific about parameter types? isn't it better to have a good documentation and only send an error msg like "Parameters have wrong type" - const requestModelParams = [ + const requestModelParams: ParamModel[] = [ { name: 'origin', ...SEVERAL_CITYCODES_PARAM_MODEL }, { name: 'departureDate', @@ -185,9 +184,13 @@ const validateRequestParamsManyOrigins = (req, res, next) => { * @param {*} res * @param {*} next */ -const validateRequestParamsOneOrigin = (req, res, next) => { +export const validateRequestParamsOneOrigin = ( + req: TypedRequestQueryWithFilter, + res: Response, + next: NextFunction +) => { // FIXME: is this really necessary to be so specific about parameter types? isn't it better to have a good documentation and only send an error msg like "Parameters have wrong type" - const requestModelParams = [ + const requestModelParams: ParamModel[] = [ { name: 'origin', ...ONE_CITYCODE_PARAM_MODEL, @@ -228,7 +231,11 @@ const validateRequestParamsOneOrigin = (req, res, next) => { * @param {*} next the next call if there is an error * @returns if no wrong type params */ -const checkWrongTypeParams = (modelParams, query, next) => { +const checkWrongTypeParams = ( + modelParams: ParamModel[], + query: QueryParams, + next: NextFunction +) => { const wrongTypeParams = validator.findWrongTypeParams(modelParams, query); if (wrongTypeParams.length > 0) { const errorMsg = modelParams @@ -252,7 +259,11 @@ const checkWrongTypeParams = (modelParams, query, next) => { * @param {*} next the next call if there is an error * @returns if no missing params */ -const checkMissingParams = (modelParams, query, next) => { +const checkMissingParams = ( + modelParams: ParamModel[], + query: QueryParams, + next: NextFunction +) => { const missingParams = validator.findMissingParams(modelParams, query); if (missingParams.length > 0) return next( @@ -264,11 +275,3 @@ const checkMissingParams = (modelParams, query, next) => { ) ); }; - -// TODO: validate request param for cheapest weekend requests -export = { - validateRequestParamsManyOrigins, - validateRequestParamsOneOrigin, - validateRequestParamsWeekend, - filterParams, -}; diff --git a/src/tests/functional.test.ts b/src/tests/endtoend.test.ts similarity index 93% rename from src/tests/functional.test.ts rename to src/tests/endtoend.test.ts index 9d0c4a0..a6c3e70 100644 --- a/src/tests/functional.test.ts +++ b/src/tests/endtoend.test.ts @@ -1,15 +1,19 @@ import request from 'supertest'; import app from '../app'; import { faker } from '@faker-js/faker'; -import User from '../user/userModel'; -import mongoose from 'mongoose'; +import User, { IUser } from '../user/userModel'; +import mongoose, { HydratedDocument } from 'mongoose'; import { DateTime } from 'luxon'; +import { Itinerary } from '../common/types'; const KIWI_DATE_FORMAT = `dd'/'LL'/'yyyy`; describe('End to end tests', () => { jest.setTimeout(15000); beforeAll(async () => { + if (!process.env.DATABASE || !process.env.DATABASE_PASSWORD) + throw Error('Missing env variables'); + const DB = process.env.DATABASE.replace( '', process.env.DATABASE_PASSWORD @@ -29,7 +33,7 @@ describe('End to end tests', () => { expect(response.text).toMatch('Pulpito'); }); // FIXME: problem with AppError and now it seems it's not taken into account so this test fails.... - test.skip('Should fail if the page does not exist', async () => { + test.skip('Should return a fail status if the page does not exist', async () => { const response = await request(app).get('/fakepage'); expect(response.statusCode).toBe(404); expect(response.body.status).toBe('fail'); @@ -41,7 +45,7 @@ describe('End to end tests', () => { expect(response.body.data.airports[0].iata_code).toEqual('CDG'); }); // FIXME: problem with AppError and now it seems it's not taken into account so this test fails.... - test.skip('API should fail if the route does not exist', async () => { + test.skip('API should return a fail status if the route does not exist', async () => { const response = await request(app).get('/api/v1/airrts/?q=CDG'); expect(response.statusCode).toBe(404); @@ -86,7 +90,7 @@ describe('End to end tests', () => { }); describe('API Signup and Auth Route', () => { - let newUser, fakeUser; + let newUser: HydratedDocument, fakeUser: Partial; beforeEach(async () => { // creating a fake user in DB @@ -190,6 +194,8 @@ describe('End to end tests', () => { expect(response.body.status).toBe('fail'); expect(response.body.message).toMatch('Please provide missing'); }); + + test.todo('should return an error when there are no parameters at all'); }); describe('API Common destinations route', () => { @@ -213,8 +219,8 @@ describe('End to end tests', () => { expect(response.statusCode).toBe(200); expect(response.body.totalResults).toBeGreaterThan(0); expect( - response.body.data[0].flights.every((flight) => - origins.includes(flight.cityCodeFrom) + response.body.data[0].itineraries.every((itinerary: Itinerary) => + origins.includes(itinerary.cityCodeFrom) ) ).toBe(true); }); @@ -241,6 +247,8 @@ describe('End to end tests', () => { expect(response.body.message).toMatch('Please provide missing'); }); + test.todo('should return an error when there are no parameters at all'); + test('should return a 400 error and a fail status if origin is not in the format MAD,BRU,BOD', async () => { const params = { ...dates, @@ -332,6 +340,7 @@ describe('End to end tests', () => { /Please provide missing parameter(.*)origin,destination/ ); }); + test.todo('should return an error if no input parameters at all'); test('should return a 400 error and a fail status if dates are not in the correct format', async () => { const dates = { diff --git a/src/user/authController.test.ts b/src/user/authController.integration.test.ts similarity index 79% rename from src/user/authController.test.ts rename to src/user/authController.integration.test.ts index 517cf19..fecd1c1 100644 --- a/src/user/authController.test.ts +++ b/src/user/authController.integration.test.ts @@ -1,17 +1,19 @@ -import { promisify } from 'util'; import authController from './authController'; // import AppError from '../utils/appError'; -import mongoose from 'mongoose'; +import mongoose, { HydratedDocument, Types } from 'mongoose'; import { faker } from '@faker-js/faker'; -import User from '../user/userModel'; +import User, { IUser } from './userModel'; import email from '../utils/email'; import jwt from 'jsonwebtoken'; import AppError from '../utils/appError'; +import { NextFunction, Request, Response } from 'express-serve-static-core'; describe('AuthController', () => { jest.setTimeout(15000); beforeAll(async () => { + if (!process.env.DATABASE || !process.env.DATABASE_PASSWORD) + throw new Error('missing env variables'); const DB = process.env.DATABASE.replace( '', process.env.DATABASE_PASSWORD @@ -25,7 +27,14 @@ describe('AuthController', () => { mongoose.disconnect(); }); - let req, res, next; + // TODO: dependency to Mongoose but we are just testing integration, this should be abstracted, right? + // TODO: improve typing of req and have something like TypedRequestQueryWithFilter + let req: Request, + res: Partial & { + data?: { user: HydratedDocument }; + message?: string; + }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -39,8 +48,6 @@ describe('AuthController', () => { this.data = obj.data; this.message = obj.message; }), - data: null, - message: null, cookie: jest.fn().mockImplementation(function () { // console.log('calling res.status'); return this; @@ -55,7 +62,8 @@ describe('AuthController', () => { describe('signToken', function () { test('should sign a token', async () => { const signSpy = jest.spyOn(jwt, 'sign'); - const fakeId = faker.database.mongodbObjectId(); + const fakeId = + faker.database.mongodbObjectId() as unknown as Types.ObjectId; const token = authController.signToken(fakeId); @@ -66,10 +74,12 @@ describe('AuthController', () => { expect.anything() ); - const decoded = await promisify(jwt.verify)( + if (!process.env.JWT_SECRET) throw Error('missing env variables'); + + const decoded = (await jwt.verify( token, process.env.JWT_SECRET - ); + )) as jwt.JwtPayload; expect(decoded.id).toBe(fakeId); }); }); @@ -79,19 +89,23 @@ describe('AuthController', () => { test('should sign a token, add it to the cookies and prepare the answer', () => { const signSpy = jest.spyOn(jwt, 'sign'); - const fakeUser = { - _id: faker.database.mongodbObjectId(), + const fakeUserFromDb: Partial> = { + _id: faker.database.mongodbObjectId() as unknown as Types.ObjectId, password: faker.internet.password(), }; const fakeStatusCode = faker.internet.httpStatusCode(); - authController.createSendToken(fakeUser, fakeStatusCode, res); + authController.createSendToken( + fakeUserFromDb as HydratedDocument, + fakeStatusCode, + res as Response + ); expect(signSpy).toHaveBeenCalled(); expect(res.cookie).toHaveBeenCalled(); - expect(fakeUser.password).toBeUndefined(); + expect(fakeUserFromDb.password).toBeUndefined(); expect(res.status).toHaveBeenCalledWith(fakeStatusCode); - expect(res.data.user._id).toBe(fakeUser._id); + expect(res.data?.user._id).toBe(fakeUserFromDb._id); }); }); }); @@ -113,20 +127,22 @@ describe('AuthController', () => { passwordConfirm: fakePassword, }; console.log(fakeUser); - req = { body: fakeUser }; + req = { body: fakeUser } as Request; - await authController.signup(req, res, next); + await authController.signup(req, res as Response, next); // const usersLengthAfterCreate = (await User.find()).length; // expect(usersLengthAfterCreate).toBe(usersLengthBeforeCreate + 1); - const createdUser = await User.findOne({ email: fakeUser.email }); + const createdUser = (await User.findOne({ + email: fakeUser.email, + })) as IUser; expect(createdUser.email.toLowerCase()).toEqual( fakeUser.email.toLowerCase() ); - const result = await User.deleteOne({ email: createdUser.email }); + const result = await User.deleteOne({ email: createdUser?.email }); console.log( `User with email ${fakeUser.email} correctly deleted after test? ${ result.deletedCount > 0 @@ -143,7 +159,8 @@ describe('AuthController', () => { }); describe('login', () => { - let newUser, fakeUser; + let newUser: HydratedDocument; + let fakeUser: Partial; beforeEach(async () => { // creating a fake user in DB @@ -170,12 +187,12 @@ describe('AuthController', () => { test('should login the user when given correct login credentials', async function () { req = { body: { email: fakeUser.email, password: fakeUser.password }, - }; + } as Request; - await authController.login(req, res, next); + await authController.login(req, res as Response, next); expect(res.status).toHaveBeenCalledWith(200); - expect(res.data.user._id).toEqual(newUser._id); + expect(res.data?.user._id).toEqual(newUser._id); }); }); describe('error cases', () => { @@ -187,9 +204,9 @@ describe('AuthController', () => { req = { body: { email: fakeUser.email, password: fakeUser.password }, - }; + } as Request; - await authController.login(req, res, next); + await authController.login(req, res as Response, next); // expect(next).toHaveBeenCalledWith(expect.any(typeof AppError)); // expect(next).toHaveBeenCalledWith( @@ -222,7 +239,14 @@ describe('AuthController', () => { }); describe('protect', () => { - let token, newUser, fakeUser; + let token; + let newUser: HydratedDocument; + let fakeUser: Partial; + + // here we need to redefine req to allow TS compilation + // otherwise it errors on req.user.id saying property user does not exist on Request + let req: Partial & { user?: HydratedDocument }; + beforeEach(async () => { // creating a fake user in DB @@ -236,12 +260,16 @@ describe('AuthController', () => { }; newUser = await User.create(fakeUser); + if (!process.env.JWT_SECRET) throw Error('missing env variables'); + token = jwt.sign({ id: newUser.id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN, }); - req.headers = { - authorization: 'Bearer ' + token, + // here we need to redefine req to allow TS compilation + // otherwise it errors on req.user.id saying property user does not exist on Request + req = { + headers: { authorization: 'Bearer ' + token }, }; }); afterEach(async () => { @@ -249,9 +277,9 @@ describe('AuthController', () => { }); describe('success case', () => { test('should grant access to protected route', async function () { - await authController.protect(req, res, next); + await authController.protect(req as Request, res as Response, next); - expect(req.user.id).toEqual(newUser.id); + expect(req.user?.id).toEqual(newUser.id); }); }); describe('error cases', () => { @@ -266,7 +294,8 @@ describe('AuthController', () => { describe('forgotPassword', function () { describe('success cases', () => { - let newUser; + let newUser: HydratedDocument; + beforeEach(async () => { // creating a fake user in DB @@ -286,7 +315,7 @@ describe('AuthController', () => { }); test('should return OK when the user email has been found and the token can be sent', async function () { - req = { body: { email: newUser.email } }; + req = { body: { email: newUser.email } } as Request; // MOCKING the send email part // TODO: mocking the nodemailer.createTransport and connecting to mailtrap.io. Right now it's connected to mailtrap.ip because we are in dev, but once in production we will need to connect to a particular mail transport sandbox and not send real email @@ -296,12 +325,12 @@ describe('AuthController', () => { //console.log('not sending an actual email'); }); - await authController.forgotPassword(req, res, next); + await authController.forgotPassword(req, res as Response, next); expect(sendPasswordResetTokenEmailSpy).toHaveBeenCalled(); // retrieve the new user from DB - const updatedUser = await User.findById(newUser.id); + const updatedUser = (await User.findById(newUser.id)) as IUser; //console.log('newUser', newUser); //console.log('updatedUser', updatedUser); expect(updatedUser.passwordResetToken).not.toBeUndefined(); @@ -311,7 +340,7 @@ describe('AuthController', () => { }); test('should return an error when the user email has been found but the token can not be sent by email for some reason', async function () { - req = { body: { email: newUser.email } }; + req = { body: { email: newUser.email } } as Request; // MOCKING the send email part // TODO: mocking the nodemailer.createTransport and connecting to mailtrap.io. Right now it's connected to mailtrap.ip because we are in dev, but once in production we will need to connect to a particular mail transport sandbox and not send real email @@ -319,7 +348,7 @@ describe('AuthController', () => { .spyOn(email, 'sendPasswordResetTokenEmail') .mockRejectedValue('Mocking Error'); - await authController.forgotPassword(req, res, next); + await authController.forgotPassword(req, res as Response, next); expect(sendPasswordResetTokenEmailSpy).toHaveBeenCalled(); expect(newUser.passwordResetToken).toBeUndefined(); @@ -338,9 +367,9 @@ describe('AuthController', () => { describe('error cases', () => { test('should return error 404 when the email is empty in the body', async function () { - req = { body: { email: '' } }; + req = { body: { email: '' } } as Request; - await authController.forgotPassword(req, res, next); + await authController.forgotPassword(req, res as Response, next); // expect(true).toBe(true); // expect(res.status).toHaveBeenCalledWith(404); @@ -355,9 +384,9 @@ describe('AuthController', () => { }); test('should return error 404 when the email can not be found', async function () { - req = { body: { email: 'milady@castle.com' } }; + req = { body: { email: 'milady@castle.com' } } as Request; - await authController.forgotPassword(req, res, next); + await authController.forgotPassword(req, res as Response, next); // expect(true).toBe(true); // expect(res.status).toHaveBeenCalledWith(404); diff --git a/src/user/authController.ts b/src/user/authController.ts index 4ba1da7..a949fa1 100644 --- a/src/user/authController.ts +++ b/src/user/authController.ts @@ -1,18 +1,28 @@ -import { promisify } from 'util'; import jwt from 'jsonwebtoken'; -import User from './userModel'; +import User, { IUser } from './userModel'; import { catchAsync } from '../utils/catchAsync'; import AppError from '../utils/appError'; import crypto from 'crypto'; import email from '../utils/email'; +import { HydratedDocument, Types } from 'mongoose'; +import { NextFunction, Request, Response } from 'express-serve-static-core'; -const signToken = (id) => { +// FIXME: dependance to mongoose - Types.ObjectId. + +const signToken = (id: Types.ObjectId) => { return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN, }); }; -const createSendToken = (user, statusCode, res) => { +// TODO: remove dependance to mongoose - HydratedDocument. +// TODO: this method does many things: clears password from output, sign token, prepares the answer, sets the cookie... + +const createSendToken = ( + user: HydratedDocument, + statusCode: number, + res: Response +) => { const token = signToken(user._id); // FIXME: added 'any' type to have TS compiler pass. Need to be added. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -39,201 +49,231 @@ const createSendToken = (user, statusCode, res) => { }; // sort of createUser but in the context of AUTH it's a signup. // it's a signup = we create the user and log in, that's why we send back the token -const signup = catchAsync(async (req, res) => { - // we could have done User.create(req.body) but we would allow API users to register themselves as 'admin' just by putting role=admin in the body. Doing this manually field by field prevents people to register as admin. - const newUser = await User.create({ - name: req.body.name, - email: req.body.email, - password: req.body.password, - passwordConfirm: req.body.passwordConfirm, - passwordChangedAt: req.body.passwordChangedAt, - }); - - createSendToken(newUser, 201, res); -}); - -const login = catchAsync(async (req, res, next) => { - const { email, password } = req.body; - - // 1) Check if email and password exist - if (!email || !password) { - return next(new AppError('Please provide email and password!', 400)); - } - - // 2) Check if user exists && password is correct - const user = await User.findOne({ email }).select('+password'); - if (!user || !(await user.isCorrectPassword(password, user.password))) { - return next( - new AppError('Incorrect email or password, or user no longer active', 401) - ); - } - - // 3) If everything ok, send token to client - - createSendToken(user, 200, res); -}); - -const protect = catchAsync(async (req, res, next) => { - // 1) Get the token and check if it exists - let token; - if ( - req.headers.authorization && - req.headers.authorization.startsWith('Bearer') - ) { - token = req.headers.authorization.split(' ')[1]; - } +// FIXME: we could type Request.body (like TypedRequestWithParam) to make sure we have certain query params +const signup = catchAsync( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (req: Request, res: Response, _next: NextFunction) => { + // we could have done User.create(req.body) but we would allow API users to register themselves as 'admin' just by putting role=admin in the body. Doing this manually field by field prevents people to register as admin. + const newUser = await User.create({ + name: req.body.name, + email: req.body.email, + password: req.body.password, + passwordConfirm: req.body.passwordConfirm, + passwordChangedAt: req.body.passwordChangedAt, + }); - // 401: data is correct but not enough to get the ressources requested - if (!token) { - return next( - new AppError('You are not logged in! Please log in to get access.', 401) - ); + createSendToken(newUser, 201, res); } - - // 2) Verification token (jwt.verify) - // jwt.verify verifies the token and calls a callback. So insteead of adding a callback, we promisify the function to make it 'cleaner' - const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET); - - // 3) Check if user still exists - // (and also check that the payload has not been altered) - const currentUser = await User.findById(decoded.id); - if (!currentUser) { - return next( - new AppError( - 'The user belonging to this token does no longer exist.', - 401 - ) - ); +); + +const login = catchAsync( + async (req: Request, res: Response, next: NextFunction) => { + const { email, password } = req.body; + + // 1) Check if email and password exist + if (!email || !password) { + return next(new AppError('Please provide email and password!', 400)); + } + + // 2) Check if user exists && password is correct + const user = await User.findOne({ email }).select('+password'); + if (!user || !(await user.isCorrectPassword(password, user.password))) { + return next( + new AppError( + 'Incorrect email or password, or user no longer active', + 401 + ) + ); + } + + // 3) If everything ok, send token to client + + createSendToken(user, 200, res); } - - // 4) Check if user changed password after the token was issued (we would need to reissue the token) - // FIXME: at the moment, the user changed password date is only set upon creation (but at the moment, there's no way to update the user) - if (currentUser.changedPasswordAfter(decoded.iat)) { - return next( - new AppError('User recently changed password! Please log in again', 401) - ); +); + +const protect = catchAsync( + async ( + req: Request & { user: HydratedDocument }, + _res: Response, + next: NextFunction + ) => { + // 1) Get the token and check if it exists + let token; + if ( + req.headers.authorization && + req.headers.authorization.startsWith('Bearer') + ) { + token = req.headers.authorization.split(' ')[1]; + } + + // 401: data is correct but not enough to get the ressources requested + if (!token) { + return next( + new AppError('You are not logged in! Please log in to get access.', 401) + ); + } + + // 2) Verification token (jwt.verify) + // jwt.verify verifies the token and calls a callback. So insteead of adding a callback, we promisify the function to make it 'cleaner' + if (!process.env.JWT_SECRET) throw Error('Missing env variables'); + const decoded = (await jwt.verify( + token, + process.env.JWT_SECRET + )) as jwt.JwtPayload; + + // 3) Check if user still exists + // (and also check that the payload has not been altered) + // FIXME: use UserRepository instead of directly User + const currentUser = await User.findById(decoded.id); + if (!currentUser) { + return next( + new AppError( + 'The user belonging to this token does no longer exist.', + 401 + ) + ); + } + + // 4) Check if user changed password after the token was issued (we would need to reissue the token) + // FIXME: at the moment, the user changed password date is only set upon creation (but at the moment, there's no way to update the user) + if (currentUser.changedPasswordAfter(decoded.iat)) { + return next( + new AppError('User recently changed password! Please log in again', 401) + ); + } + + // GRANT ACCESS TO PROTECTED ROUTE + req.user = currentUser; + next(); } - - // GRANT ACCESS TO PROTECTED ROUTE - req.user = currentUser; - next(); -}); +); /** * Request a token to reset the password. Token is sent by email. */ -const forgotPassword = catchAsync(async (req, res, next) => { - // 1) Get user based on POSTed email - - const user = await User.findOne({ email: req.body.email }); - - if (!user) { - return next( - new AppError('There is no active user with this email address.', 404) - ); - } - - // 2) Generate the random reset token - const resetToken = user.createPasswordResetToken(); - // if we don't set up this option, we can't save the reset token - // (and we don't need to validate since there are no inputs here) - await user.save({ validateBeforeSave: false }); - - // 3) Send it to user's email - try { - await email.sendPasswordResetTokenEmail(req, user.email, resetToken); - - res.status(200).json({ - status: 'success', - message: 'Token sent to email!', - }); - } catch (err) { - console.error(err); - - // if there has been an error, we reset the password reset token thing - user.passwordResetToken = undefined; - user.passwordExpires = undefined; +const forgotPassword = catchAsync( + async (req: Request, res: Response, next: NextFunction) => { + // 1) Get user based on POSTed email + + const user = await User.findOne({ email: req.body.email }); + + if (!user) { + return next( + new AppError('There is no active user with this email address.', 404) + ); + } + + // 2) Generate the random reset token + const resetToken = user.createPasswordResetToken(); + // if we don't set up this option, we can't save the reset token + // (and we don't need to validate since there are no inputs here) await user.save({ validateBeforeSave: false }); - return next( - new AppError( - 'There was en error sending the email. Please try again later!', - 500 - ) - ); + // 3) Send it to user's email + try { + await email.sendPasswordResetTokenEmail(req, user.email, resetToken); + + res.status(200).json({ + status: 'success', + message: 'Token sent to email!', + }); + } catch (err) { + console.error(err); + + // if there has been an error, we reset the password reset token thing + user.passwordResetToken = undefined; + user.passwordResetExpiresAt = undefined; + await user.save({ validateBeforeSave: false }); + + return next( + new AppError( + 'There was en error sending the email. Please try again later!', + 500 + ) + ); + } } -}); +); /** * Actually resets password using the token received by email */ -const resetPassword = catchAsync(async (req, res, next) => { - // 1) Get user based on the token - const token = req.params.token; - const { password, passwordConfirm } = req.body; - if (!token || !password || !passwordConfirm) { - return next( - new AppError( - 'Please provide reset token, password and password confirmation!', - 400 - ) - ); - } - - const encrypted = crypto.createHash('sha256').update(token).digest('hex'); - - const user = await User.findOne({ - passwordResetToken: encrypted, - passwordResetExpiresAt: { $gt: Date.now() }, - }); +const resetPassword = catchAsync( + async (req: Request, res: Response, next: NextFunction) => { + // 1) Get user based on the token + const token = req.params.token; + const { password, passwordConfirm } = req.body; + if (!token || !password || !passwordConfirm) { + return next( + new AppError( + 'Please provide reset token, password and password confirmation!', + 400 + ) + ); + } + + const encrypted = crypto.createHash('sha256').update(token).digest('hex'); + + const user = await User.findOne({ + passwordResetToken: encrypted, + passwordResetExpiresAt: { $gt: Date.now() }, + }); - if (!user) { - return next(new AppError('Token is invalid or has expired', 400)); - } + if (!user) { + return next(new AppError('Token is invalid or has expired', 400)); + } - // 3) Update password for the user - user.password = password; - user.passwordConfirm = passwordConfirm; - user.passwordResetToken = undefined; - user.passwordResetExpiresAt = undefined; + // 3) Update password for the user + user.password = password; + user.passwordConfirm = passwordConfirm; + user.passwordResetToken = undefined; + user.passwordResetExpiresAt = undefined; - await user.save(); + await user.save(); - // 4) Log the user in, send JWT - createSendToken(user, 200, res); -}); + // 4) Log the user in, send JWT + createSendToken(user, 200, res); + } +); /** * Update password */ -const updateMyPassword = catchAsync(async (req, res, next) => { - // 1) get user - const user = await User.findById(req.user._id).select('+password'); - - const { current, password, passwordConfirm } = req.body; - if (!current || !password || !passwordConfirm) { - return next( - new AppError( - 'Please provide current password, new password and password confirmation!', - 400 - ) - ); +const updateMyPassword = catchAsync( + async ( + req: Request & { user: HydratedDocument }, + res: Response, + next: NextFunction + ) => { + // 1) get user + const user = await User.findById(req.user._id).select('+password'); + + const { current, password, passwordConfirm } = req.body; + if (!current || !password || !passwordConfirm) { + return next( + new AppError( + 'Please provide current password, new password and password confirmation!', + 400 + ) + ); + } + + // 2) Check if POSTed current password is correct + if (!(await user.isCorrectPassword(current, user.password))) { + return next(new AppError('Your current password is wrong.', 401)); + } + + // 3) If so, update password + user.password = password; + user.passwordConfirm = passwordConfirm; + + await user.save(); + + // 4) Log user in, send JWT + createSendToken(user, 200, res); } - - // 2) Check if POSTed current password is correct - if (!(await user.isCorrectPassword(current, user.password))) { - return next(new AppError('Your current password is wrong.', 401)); - } - - // 3) If so, update password - user.password = password; - user.passwordConfirm = passwordConfirm; - - await user.save(); - - // 4) Log user in, send JWT - createSendToken(user, 200, res); -}); +); export = { createSendToken, diff --git a/src/user/userController.test.ts b/src/user/userController.integration.test.ts similarity index 84% rename from src/user/userController.test.ts rename to src/user/userController.integration.test.ts index 51dade4..f9b5b3f 100644 --- a/src/user/userController.test.ts +++ b/src/user/userController.integration.test.ts @@ -1,12 +1,17 @@ import userController from './userController'; -import User from './userModel'; -import mongoose from 'mongoose'; +import User, { IUser } from './userModel'; +import mongoose, { HydratedDocument } from 'mongoose'; import { faker } from '@faker-js/faker'; +import { NextFunction, Request, Response } from 'express-serve-static-core'; describe('UserController', () => { jest.setTimeout(15000); + let newUser: HydratedDocument; + let fakeUser: Partial; beforeAll(async () => { + if (!process.env.DATABASE || !process.env.DATABASE_PASSWORD) + throw new Error('missing env variables'); const DB = process.env.DATABASE.replace( '', process.env.DATABASE_PASSWORD @@ -20,7 +25,9 @@ describe('UserController', () => { mongoose.disconnect(); }); - let req, res, next; + let req: Request & { user: HydratedDocument }, + res: Partial & Partial<{ data: any; message: string }>, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -32,8 +39,8 @@ describe('UserController', () => { this.data = obj.data; this.message = obj.message; }), - data: null, - message: null, + data: undefined, + message: '', }; next = jest.fn().mockImplementation(function (err) { @@ -46,7 +53,7 @@ describe('UserController', () => { test('should get all users', async () => { // console.log(allUsers); - await userController.getAllUsers(req, res); + await userController.getAllUsers(req, res as Response); console.log(res.data.users); expect(res.status).toHaveBeenCalledWith(200); @@ -58,7 +65,6 @@ describe('UserController', () => { }); describe('updateMe', () => { - let newUser, fakeUser; beforeEach(async () => { // creating a fake user in DB @@ -85,9 +91,9 @@ describe('UserController', () => { req = { body: UPDATED_PROPERTIES, user: { id: newUser.id }, - }; + } as Request & { user: HydratedDocument }; - await userController.updateMe(req, res, next); + await userController.updateMe(req, res as Response, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.data.user.id).toEqual(newUser.id); @@ -104,7 +110,6 @@ describe('UserController', () => { }); describe('airports', () => { - let newUser, fakeUser; beforeEach(async () => { // creating a fake user in DB @@ -128,9 +133,9 @@ describe('UserController', () => { user: { id: newUser.id, }, - }; + } as Request & { user: HydratedDocument }; - await userController.getFavAirports(req, res, next); + await userController.getFavAirports(req, res as Response, next); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data.favAirports)).toBe(true); @@ -147,9 +152,9 @@ describe('UserController', () => { id: newUser.id, }, body: { airport: 'JFK' }, - }; + } as Request & { user: HydratedDocument }; - await userController.addFavAirport(req, res, next); + await userController.addFavAirport(req, res as Response, next); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data.favAirports)).toBe(true); @@ -177,7 +182,7 @@ describe('UserController', () => { id: newUser.id, }, body: { airport: 'JFK' }, - }; + } as Request & { user: HydratedDocument }; // add an airport to that fake user await User.findByIdAndUpdate(newUser.id, { @@ -185,7 +190,7 @@ describe('UserController', () => { }); // and then remove it ... - await userController.removeFavAirport(req, res, next); + await userController.removeFavAirport(req, res as Response, next); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data.favAirports)).toBe(true); @@ -207,7 +212,6 @@ describe('UserController', () => { }); describe('deleteMe', () => { - let newUser, fakeUser; beforeEach(async () => { // creating a fake user in DB @@ -231,12 +235,12 @@ describe('UserController', () => { user: { id: newUser.id, }, - }; + } as Request & { user: HydratedDocument }; const user = await User.findById(newUser.id); expect(user).not.toBeUndefined(); - await userController.deleteMe(req, res, next); + await userController.deleteMe(req, res as Response, next); const updatedUser = await User.findById(newUser.id); expect(res.status).toHaveBeenCalledWith(204); diff --git a/src/user/userController.ts b/src/user/userController.ts index 67f2e01..079c24e 100644 --- a/src/user/userController.ts +++ b/src/user/userController.ts @@ -1,16 +1,19 @@ -import User from './userModel'; import { catchAsync } from '../utils/catchAsync'; import AppError from '../utils/appError'; import utils from '../utils/utils'; import { findByIataCode } from '../airports/airportService'; +import { UserRepository } from './userRepository'; +import { NextFunction, Request, Response } from 'express-serve-static-core'; +import { HydratedDocument } from 'mongoose'; +import { IUser } from './userModel'; /** * Get all users * @param {*} req * @param {*} res */ -const getAllUsers = async (req, res) => { - const users = await User.find(); +const getAllUsers = async (req: Request, res: Response) => { + const users = await UserRepository.all(); res.status(200).json({ status: 'success', @@ -24,135 +27,147 @@ const getAllUsers = async (req, res) => { /** * Updates currently logged in user */ -const updateMe = catchAsync(async (req, res, next) => { - // 1) Error if user POSTs password data - if (req.body.password || req.body.passwordConfirm) { - return next( - new AppError( - 'This route is not for password updates. Please use /updateMyPassword', - 400 - ) - ); - } +// TODO: improve, it should not be coupled to mongoose implementation (HydratedDocument) +const updateMe = catchAsync( + async ( + req: Request & { user: HydratedDocument }, + res: Response, + next: NextFunction + ) => { + // 1) Error if user POSTs password data + if (req.body.password || req.body.passwordConfirm) { + return next( + new AppError( + 'This route is not for password updates. Please use /updateMyPassword', + 400 + ) + ); + } - const allowedFields = ['name', 'email']; + const allowedFields = ['name', 'email']; - // 2) Filter out unwanted fields names that are not allowed to be updated, to avoid users to set themselves as admin, for example - const filteredBody = utils.filterObj(req.body, allowedFields); + // 2) Filter out unwanted fields names that are not allowed to be updated, to avoid users to set themselves as admin, for example + // FIXME: ça c'est une business rule + const filteredBody = utils.filterObj(req.body, allowedFields); - // 3) Update user - const updatedUser = await User.findByIdAndUpdate(req.user.id, filteredBody, { - new: true, - runValidators: true, // fields validator will be run, for example isEmail() - }); + // 3) Update user + const updatedUser = await UserRepository.updateOne( + req.user.id, + filteredBody + ); - res.status(200).json({ - status: 'success', - data: { - user: updatedUser, - }, - }); -}); + res.status(200).json({ + status: 'success', + data: { + user: updatedUser, + }, + }); + } +); /** * Deletes currently logged-in user */ -const deleteMe = catchAsync(async (req, res) => { - // 3) Update user - await User.findByIdAndUpdate(req.user.id, { - active: false, - }); - - res.status(204).json({ - status: 'success', - data: null, - }); -}); +const deleteMe = catchAsync( + async (req: Request & { user: HydratedDocument }, res: Response) => { + // 3) Update user + await UserRepository.deleteOne(req.user.id); + + res.status(204).json({ + status: 'success', + data: null, + }); + } +); /** * Get favorite airports for the currently logged-in user */ -const getFavAirports = catchAsync(async (req, res) => { - const user = await User.findById(req.user.id); - - res.status(200).json({ - status: 'success', - data: { - favAirports: user.favAirports, - }, - }); -}); +const getFavAirports = catchAsync( + async (req: Request & { user: HydratedDocument }, res: Response) => { + const user = await UserRepository.findOne(req.user.id); + + res.status(200).json({ + status: 'success', + data: { + favAirports: user.favAirports, + }, + }); + } +); /** * Add a favorite airport to the list of favorite airports for that user */ -const addFavAirport = catchAsync(async (req, res, next) => { - if (!req.body.airport) { - return next(new AppError('Please specify an airport', 400)); - } - if (!findByIataCode(req.body.airport)) { - return next( - new AppError( - `We haven't found any airport with this IATA code. Please retry with an existing IATA code`, - 400 - ) - ); - } - - const updatedUser = await User.findByIdAndUpdate( - req.user.id, - { - $addToSet: { favAirports: req.body.airport }, - }, - { - new: true, +const addFavAirportToUser = catchAsync( + async ( + req: Request & { user: HydratedDocument }, + res: Response, + next: NextFunction + ) => { + if (!req.body.airport) { + return next(new AppError('Please specify an airport', 400)); + } + if (!findByIataCode(req.body.airport)) { + return next( + new AppError( + `We haven't found any airport with this IATA code. Please retry with an existing IATA code`, + 400 + ) + ); } - ); - res.status(200).json({ - status: 'success', - data: { - favAirports: updatedUser.favAirports, - }, - }); -}); + const updatedUser = (await UserRepository.addFavAirportToUser( + req.user.id, + req.body.airport + )) as IUser; + + res.status(200).json({ + status: 'success', + data: { + favAirports: updatedUser.favAirports, + }, + }); + } +); /** * Remove a favorite airport from the list of favorite airports for that user */ -const removeFavAirport = catchAsync(async (req, res, next) => { - if (!req.body.airport) { - return next(new AppError('Please specify an airport', 400)); - } - if (!findByIataCode(req.body.airport)) { - return next( - new AppError( - `We haven't found any airport with this IATA code. Please retry with an existing IATA code`, - 400 - ) - ); - } - - const updatedUser = await User.findByIdAndUpdate( - req.user.id, - { - $pullAll: { favAirports: [req.body.airport] }, - }, - { - new: true, +const removeFavAirport = catchAsync( + async ( + req: Request & { user: HydratedDocument }, + res: Response, + next: NextFunction + ) => { + if (!req.body.airport) { + return next(new AppError('Please specify an airport', 400)); + } + if (!findByIataCode(req.body.airport)) { + return next( + new AppError( + `We haven't found any airport with this IATA code. Please retry with an existing IATA code`, + 400 + ) + ); } - ); - res.status(200).json({ - status: 'success', - data: { - favAirports: updatedUser.favAirports, - }, - }); -}); + const updatedUser = await UserRepository.removeFavAirportFromUser( + req.user.id, + req.body.airport + ); + + res.status(200).json({ + status: 'success', + data: { + favAirports: updatedUser.favAirports, + }, + }); + } +); export = { - addFavAirport, + addFavAirport: addFavAirportToUser, deleteMe, getAllUsers, getFavAirports, diff --git a/src/user/userModel.ts b/src/user/userModel.ts index d739a0c..ba07035 100644 --- a/src/user/userModel.ts +++ b/src/user/userModel.ts @@ -1,9 +1,33 @@ -import mongoose from 'mongoose'; +import { Schema, model, Types, Model } from 'mongoose'; import crypto from 'crypto'; import validator from 'validator'; import bcrypt from 'bcryptjs'; -const userSchema = new mongoose.Schema({ +export interface IUser { + name: string; + email: string; + favAirports?: Types.Array; + password: string; + passwordConfirm?: string; + passwordChangedAt: number; + passwordResetToken: string; + passwordResetExpiresAt: number; + active: boolean; +} + +interface IUserMethods { + changedPasswordAfter(JWTTimestamp: number): boolean; + isCorrectPassword( + candidatePassword: string, + userPassword: string + ): Promise; + createPasswordResetToken(): string; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +type UserModel = Model; + +const userSchema = new Schema({ name: { type: String, required: [true, 'Please tell us your name!'], @@ -31,7 +55,7 @@ const userSchema = new mongoose.Schema({ validate: [ // this only works on CREATE and SAVE !!! // so for UPDSTES we need to SAVE instead of findOneAndUpdate - function (el) { + function (el: string) { return this.password === el; }, 'Passwords are not the same!', @@ -71,55 +95,64 @@ userSchema.pre('save', function (next) { // any query that starts with 'find' // find* queries only retrieves active users (that do not have 'active' as false) -userSchema.pre(/^find/, function (next) { +userSchema.pre(/^find/, function (next) { // Query middleware, so 'this' points to query // instead of 'active:true' we use that filter, to account for documents that do not have the field 'active' set. this.find({ active: { $ne: false } }); next(); }); -userSchema.methods.isCorrectPassword = async function ( - candidatePassword, - userPassword -) { - // we can not use this.password because we decided password is not available in the output (because of select:false) - // so we send it in the arguments - return await bcrypt.compare(candidatePassword, userPassword); -}; - -userSchema.methods.changedPasswordAfter = function (JWTTimestamp) { - if (this.passwordChangedAt) { - // const changedTimestamp = parseInt( - // this.passwordChangedAt.getTime() / 1000, - // 10 - // ); - const changedTimestamp = this.passwordChangedAt.getTime() / 1000; - - // because some users do not have this property - return JWTTimestamp < changedTimestamp; +userSchema.method( + 'isCorrectPassword', + async function isCorrectPassword( + candidatePassword: string, + userPassword: string + ) { + // we can not use this.password because we decided password is not available in the output (because of select:false) + // so we send it in the arguments + return await bcrypt.compare(candidatePassword, userPassword); + } +); + +userSchema.method( + 'changedPasswordAfter', + function changedPasswordAfter(JWTTimestamp: number) { + if (this.passwordChangedAt) { + // const changedTimestamp = parseInt( + // this.passwordChangedAt.getTime() / 1000, + // 10 + // ); + const changedTimestamp = this.passwordChangedAt.getTime() / 1000; + + // because some users do not have this property + return JWTTimestamp < changedTimestamp; + } + // false means NOT changed + return false; } - // false means NOT changed - return false; -}; - -userSchema.methods.createPasswordResetToken = function () { - // gemerate the token - // we don't need such security so we can use crypto library instead of bcryptjs library - - const resetToken = crypto.randomBytes(32).toString('hex'); - - // we are going to store the reset token in DB but as usually we are going to store it encrypted - // same here: encryption does not need to be of the highest security so we can use crypto library - this.passwordResetToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - // expiration after 10 minutes - this.passwordResetExpiresAt = Date.now() + 10 * 60 * 1000; - - // we send the non encrypted version to email. - return resetToken; -}; - -const User = mongoose.model('User', userSchema); +); + +userSchema.method( + 'createPasswordResetToken', + function createPasswordResetToken() { + // gemerate the token + // we don't need such security so we can use crypto library instead of bcryptjs library + + const resetToken = crypto.randomBytes(32).toString('hex'); + + // we are going to store the reset token in DB but as usually we are going to store it encrypted + // same here: encryption does not need to be of the highest security so we can use crypto library + this.passwordResetToken = crypto + .createHash('sha256') + .update(resetToken) + .digest('hex'); + // expiration after 10 minutes + this.passwordResetExpiresAt = Date.now() + 10 * 60 * 1000; + + // we send the non encrypted version to email. + return resetToken; + } +); + +const User = model('User', userSchema); export default User; diff --git a/src/user/userRepository.ts b/src/user/userRepository.ts new file mode 100644 index 0000000..8d6af73 --- /dev/null +++ b/src/user/userRepository.ts @@ -0,0 +1,49 @@ +import { IataCode } from '../common/types'; +import User, { IUser } from './userModel'; + +export class UserRepository { + static updateOne = async (id: string, update: Partial) => { + return await User.findByIdAndUpdate(id, update, { + new: true, + runValidators: true, // fields validator will be run, for example isEmail() + }); + }; + + static all = async () => { + return await User.find(); + }; + + static findOne = async (id: string) => { + return await User.findById(id); + }; + + static deleteOne = async (id: string) => { + await User.findByIdAndUpdate(id, { + active: false, + }); + }; + + static addFavAirportToUser = async (id: string, airport: IataCode) => { + return await User.findByIdAndUpdate( + id, + { + $addToSet: { favAirports: airport }, + }, + { + new: true, + } + ); + }; + + static removeFavAirportFromUser = async (id: string, airport: IataCode) => { + return await User.findByIdAndUpdate( + id, + { + $pullAll: { favAirports: [airport] }, + }, + { + new: true, + } + ); + }; +} diff --git a/src/utils/apiHelper.ts b/src/utils/apiHelper.ts index de3431d..4baf231 100644 --- a/src/utils/apiHelper.ts +++ b/src/utils/apiHelper.ts @@ -1,6 +1,32 @@ import { Settings, Duration, DateTime } from 'luxon'; +// import groupByToMap from 'core-js-pure/actual/array/group-by-to-map'; +import groupBy from 'lodash.groupby'; +import { + DestinationWithItineraries, + IataCode, + Itinerary, + KiwiItinerary, + KiwiRoute, + RegularFlightsParams, + WeekendFlightsParams, +} from '../common/types'; Settings.defaultLocale = 'fr'; +const groupByDestination = ( + itineraries: Itinerary[] +): Map => { + const destinationsDictionary = groupBy(itineraries, 'cityTo'); + // groupByToMap(itineraries, (item) => { + // return item.cityTo; + // }); + + const destinations = new Map(); + for (const key in destinationsDictionary) { + destinations.set(key, destinationsDictionary[key]); + } + return destinations; +}; + /** * Filters destinations according to if they can be reached from all the origins. * Filters destinations that can not be reached from each origin. @@ -10,7 +36,10 @@ Settings.defaultLocale = 'fr'; * @param {*} origins an array of the origins from which we are departing (as iata code, i.e. [ 'MAD', 'CDG', 'BRU' ]) * @returns an array of destination cities */ -const filterDestinationCities = (destinations, origins) => { +const filterDestinationCities = ( + destinations: Map, + origins: IataCode[] +) => { return Array.from(destinations.keys()).filter((key) => isCommonDestination(destinations.get(key), origins) ); @@ -22,19 +51,23 @@ const filterDestinationCities = (destinations, origins) => { * @param {*} origins array of origins (as iata code, i.e. [ 'MAD', 'CDG', 'BRU' ]) * @returns true if all the origins can be reached from that destination, false otherwise */ -const isCommonDestination = (destination, origins) => { - // for each origin ('every'), I want to find it at least once as an origin ('cityCodeFrom' or 'flyFrom') in the list of flights corresponding to this destination ('destinations.get(key)') - // be careful with cityCodeFrom and flyFrom : for metropolitan areas like London NewYork Paris and others, cityCodeFrom is the iata code of the metropolitan area, and flyFrom the actual airport - // for example flyFrom=ORY and cityCodeFrom=PAR +const isCommonDestination = (itineraries: Itinerary[], origins: IataCode[]) => { + // for each origin ('every'), I want to find it at least once as an origin ('cityCodeFrom' or 'flyFrom') in the list of itineraries ('destinations.get(key)') return origins.every( (origin) => - destination.findIndex( - (value) => value.cityCodeFrom === origin || value.flyFrom === origin + itineraries.findIndex((itinerary) => + itineraryHasOrigin(itinerary, origin) ) > -1 ); }; +// be careful with cityCodeFrom and flyFrom : for metropolitan areas like London NewYork Paris and others, cityCodeFrom is the iata code of the metropolitan area, and flyFrom the actual airport +// for example flyFrom=ORY and cityCodeFrom=PAR +const itineraryHasOrigin = (itinerary: Itinerary, origin: IataCode) => { + return itinerary.cityCodeFrom === origin || itinerary.flyFrom === origin; +}; + /** * Prepare an object with all the flights corresponding to that destination, and compute some extra values like total duration, total price... * @param {*} dest the city name of the destination where we are going, i.e. 'Budapest' @@ -42,168 +75,251 @@ const isCommonDestination = (destination, origins) => { * @param {*} passengersPerOrigin a map representing the number of passengers per origin (as iata code), like {"MAD" => 1, "BOD" => 2} * @returns an object for that destination, with aggregated info */ -const prepareItineraryData = (dest, itineraries, passengersPerOrigin) => { - // FIXME: I had to add 'any' otherwise the TypeScript compiler would not allow "sequentially added properties". I need to create a type or an interface - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const itinerary: any = { cityTo: dest }; +const prepareDestinationData = ( + dest: string, + itineraries: Itinerary[], + passengersPerOrigin: Map +): DestinationWithItineraries => { + const destination: Partial = { + cityTo: dest, + }; // corresponding origins to that particular destination, we remove flights that do not go to that destination // itinerary.flights will have one item per origin - itinerary.flights = itineraries.filter( + destination.itineraries = itineraries.filter( (itinerary) => itinerary.cityTo === dest ); // common to all origins, for that particular destination - itinerary.countryTo = itinerary.flights[0].countryTo.name; - itinerary.cityCodeTo = itinerary.flights[0].cityCodeTo; + destination.countryTo = destination.itineraries[0].countryTo.name; + destination.cityCodeTo = destination.itineraries[0].cityCodeTo; // compute total price - itinerary.price = itinerary.flights.reduce( - (sum, flight) => sum + flight.price, + destination.price = destination.itineraries.reduce( + (sum, itinerary) => sum + itinerary.price, 0 ); // total distance = the sum of the distance for each destination multiplied by the nb of passengers for that destination. It's not the same to fly 10 persons from Madrid to London and 1 from Madrid to Bangkok, than 1 from Madrid to London and 10 from Madrid to BKK. - itinerary.distance = Math.trunc( - itinerary.flights.reduce( - (sum, flight) => + destination.distance = Math.trunc( + destination.itineraries.reduce( + (sum, itinerary) => sum + - (passengersPerOrigin.get(flight.flyFrom) ?? - passengersPerOrigin.get(flight.cityCodeFrom)) * - flight.distance, + (passengersPerOrigin.get(itinerary.flyFrom) ?? + passengersPerOrigin.get(itinerary.cityCodeFrom)) * + itinerary.distance, 0 ) ); // total duration departure - itinerary.totalDurationDepartureInMinutes = itinerary.flights.reduce( - (sum, flight) => sum + flight.duration.departure / 60, - 0 - ); - - // total duration return - itinerary.totalDurationReturnInMinutes = itinerary.flights.reduce( - (sum, flight) => sum + flight.duration['return'] / 60, - 0 - ); - - return itinerary; + // destination.totalDurationDepartureInMinutes = destination.itineraries.reduce( + // (sum, itinerary) => sum + itinerary.duration.departure / 60, + // 0 + // ); + + // // total duration return + // destination.totalDurationReturnInMinutes = destination.itineraries.reduce( + // (sum, flight) => sum + flight.duration['return'] / 60, + // 0 + // ); + + return destination as DestinationWithItineraries; }; -/** - * TODO: merge with prepareItineraryData - * Remove unnecessary data from API payload and regroup some other data by oneway and return flights - * @param {*} input itinerary to be cleaned. Won't be mutated. - * @returns a copy of the itinerary, but cleaned. - */ -const cleanItineraryData = (input) => { - const itinerary = Object.assign({}, input); - - delete itinerary.type_flights; - delete itinerary.nightsInDest; - delete itinerary.quality; - delete itinerary.conversion; - // delete itinerary.fare; - delete itinerary.bags_price; - delete itinerary.baglimit; - delete itinerary.availability; - delete itinerary.countryFrom; - // delete itinerary.countryTo; - delete itinerary.routes; - - const filteredRoute = itinerary.route.map((r) => { - delete r.fare_basis; - delete r.fare_category; - delete r.fare_classes; - delete r.fare_family; - delete r.bags_recheck_required; - delete r.vi_connection; - delete r.guarantee; - delete r.equipment; - delete r.vehicle_type; - return r; - }); +const convertKiwiItineraryToItinerary = ( + kiwiItinerary: KiwiItinerary +): Itinerary => { + const itinerary: Partial = { + flyFrom: kiwiItinerary.flyFrom, + flyTo: kiwiItinerary.flyTo, + cityFrom: kiwiItinerary.cityFrom, + cityCodeFrom: kiwiItinerary.cityCodeFrom, + cityTo: kiwiItinerary.cityTo, + cityCodeTo: kiwiItinerary.cityCodeTo, + countryTo: kiwiItinerary.countryTo, + distance: kiwiItinerary.distance, + duration: kiwiItinerary.duration, + fare: kiwiItinerary.fare, + price: kiwiItinerary.price, + deep_link: kiwiItinerary.deep_link, + // route: input.route.map(convertKiwiRouteToRoute), + }; - const onewayFlights = filteredRoute.filter((r) => r.return === 0); - const returnFlights = filteredRoute.filter((r) => r.return === 1); + const onewayKiwiRoutes = kiwiItinerary.route.filter( + (route) => route.return === 0 + ); + const returnKiwiRoutes = kiwiItinerary.route.filter( + (route) => route.return === 1 + ); - // refactor info about each set of flights - // FIXME: improve performance, this usually takes 0.6 or 0.8ms to complete (and we need to repeat that operation 600-700 times since there are 600-700 itineraries to be cleaned). Maybe an option is to completely remove that part and not clean-refactor data? + // FIXME: (from cleanItineraryData) : improve performance, this usually takes 0.6 or 0.8ms to complete (and we need to repeat that operation 600-700 times since there are 600-700 itineraries to be cleaned). Maybe an option is to completely remove that part and not clean-refactor data? // If we remove that part, indeed cleanItineraryData is only 3 to 5 ms instead of 250-300 ms - // FIXME: create a type or an interface - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const route: any = { - oneway: { - flights: onewayFlights, - local_departure: formatTime(onewayFlights[0].local_departure), - local_arrival: formatTime( - onewayFlights[onewayFlights.length - 1].local_arrival - ), - utc_departure: formatTime(onewayFlights[0].utc_departure), - utc_arrival: formatTime( - onewayFlights[onewayFlights.length - 1].utc_arrival - ), - connections: extractConnections(onewayFlights), - flyFrom: onewayFlights[0].flyFrom, - flyTo: onewayFlights[onewayFlights.length - 1].flyTo, - duration: Duration.fromMillis( - itinerary.duration.departure * 1000 - ).toFormat("hh'h'mm"), - }, + itinerary.onewayRoute = { + connections: extractConnections(onewayKiwiRoutes), + local_departure: formatTime(onewayKiwiRoutes[0].local_departure), + local_arrival: formatTime( + onewayKiwiRoutes[onewayKiwiRoutes.length - 1].local_arrival + ), + utc_departure: formatTime(onewayKiwiRoutes[0].utc_departure), + utc_arrival: formatTime( + onewayKiwiRoutes[onewayKiwiRoutes.length - 1].utc_arrival + ), + duration: Duration.fromMillis( + kiwiItinerary.duration.departure * 1000 + ).toFormat("hh'h'mm"), }; - // if there are return flights - if (returnFlights && returnFlights.length > 0) { - route.return = { - flights: returnFlights, - local_departure: formatTime(returnFlights[0].local_departure), + if (returnKiwiRoutes && returnKiwiRoutes.length > 0) { + itinerary.returnRoute = { + connections: extractConnections(returnKiwiRoutes), + local_departure: formatTime(returnKiwiRoutes[0].local_departure), local_arrival: formatTime( - returnFlights[returnFlights.length - 1].local_arrival + returnKiwiRoutes[returnKiwiRoutes.length - 1].local_arrival ), - utc_departure: formatTime(returnFlights[0].utc_departure), + utc_departure: formatTime(returnKiwiRoutes[0].utc_departure), utc_arrival: formatTime( - returnFlights[returnFlights.length - 1].utc_arrival - ), - connections: extractConnections(returnFlights), - flyFrom: returnFlights[0].flyFrom, - flyTo: returnFlights[returnFlights.length - 1].flyTo, - duration: Duration.fromMillis(itinerary.duration.return * 1000).toFormat( - "hh'h'mm" + returnKiwiRoutes[returnKiwiRoutes.length - 1].utc_arrival ), + duration: Duration.fromMillis( + kiwiItinerary.duration.return * 1000 + ).toFormat("hh'h'mm"), }; } - itinerary.route = route; - - delete itinerary.tracking_pixel; - delete itinerary.facilitated_booking_available; - delete itinerary.pnr_count; - delete itinerary.has_airport_change; - delete itinerary.technical_stops; - delete itinerary.throw_away_ticketing; - delete itinerary.hidden_city_ticketing; - delete itinerary.virtual_interlining; - delete itinerary.transfers; - delete itinerary.booking_token; - // delete itinerary.deep_link; - delete itinerary.local_arrival; - delete itinerary.local_departure; - delete itinerary.utc_arrival; - delete itinerary.utc_departure; - - return itinerary; + return itinerary as Itinerary; }; +// const convertKiwiRouteToRoute = (input: KiwiRoute): Route => { +// return { +// flyFrom: input.flyFrom, +// flyTo: input.flyTo, +// cityFrom: input.cityFrom, +// cityCodeFrom: input.cityCodeFrom, +// cityTo: input.cityTo, +// cityCodeTo: input.cityCodeTo, +// return: input.return, +// local_arrival: input.local_arrival, +// utc_arrival: input.utc_arrival, +// local_departure: input.local_departure, +// utc_departure: input.utc_departure, +// }; +// }; + +/** + * TODO: merge with prepareItineraryData + * Remove unnecessary data from API payload and regroup some other data by oneway and return flights + * @param {*} input itinerary to be cleaned. Won't be mutated. + * @returns a copy of the itinerary, but cleaned. + */ +// const cleanItineraryData = (input: Itinerary): Itinerary => { +// const itinerary = Object.assign({}, input); + +// // delete itinerary.type_flights; +// // delete itinerary.nightsInDest; +// // delete itinerary.quality; +// // delete itinerary.conversion; +// // // delete itinerary.fare; +// // delete itinerary.bags_price; +// // delete itinerary.baglimit; +// // delete itinerary.availability; +// // delete itinerary.countryFrom; +// // // delete itinerary.countryTo; +// // delete itinerary.routes; + +// const filteredRoute = itinerary.route.map((r) => { +// // delete r.fare_basis; +// // delete r.fare_category; +// // delete r.fare_classes; +// // delete r.fare_family; +// // delete r.bags_recheck_required; +// // delete r.vi_connection; +// // delete r.guarantee; +// // delete r.equipment; +// // delete r.vehicle_type; +// return r; +// }); + +// const onewayFlights = filteredRoute.filter((r) => r.return === 0); +// const returnFlights = filteredRoute.filter((r) => r.return === 1); + +// // refactor info about each set of flights +// // FIXME: improve performance, this usually takes 0.6 or 0.8ms to complete (and we need to repeat that operation 600-700 times since there are 600-700 itineraries to be cleaned). Maybe an option is to completely remove that part and not clean-refactor data? +// // If we remove that part, indeed cleanItineraryData is only 3 to 5 ms instead of 250-300 ms +// // FIXME: create a type or an interface +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// const route: any = { +// oneway: { +// flyFrom: onewayFlights[0].flyFrom, +// flyTo: onewayFlights[onewayFlights.length - 1].flyTo, +// duration: Duration.fromMillis( +// itinerary.duration.departure * 1000 +// ).toFormat("hh'h'mm"), +// flights: onewayFlights, +// connections: extractConnections(onewayFlights), +// local_departure: formatTime(onewayFlights[0].local_departure), +// local_arrival: formatTime( +// onewayFlights[onewayFlights.length - 1].local_arrival +// ), +// utc_departure: formatTime(onewayFlights[0].utc_departure), +// utc_arrival: formatTime( +// onewayFlights[onewayFlights.length - 1].utc_arrival +// ), +// }, +// }; + +// // if there are return flights +// if (returnFlights && returnFlights.length > 0) { +// route.return = { +// flyFrom: returnFlights[0].flyFrom, +// flyTo: returnFlights[returnFlights.length - 1].flyTo, +// duration: Duration.fromMillis(itinerary.duration.return * 1000).toFormat( +// "hh'h'mm" +// ), +// flights: returnFlights, +// connections: extractConnections(returnFlights), +// local_departure: formatTime(returnFlights[0].local_departure), +// local_arrival: formatTime( +// returnFlights[returnFlights.length - 1].local_arrival +// ), +// utc_departure: formatTime(returnFlights[0].utc_departure), +// utc_arrival: formatTime( +// returnFlights[returnFlights.length - 1].utc_arrival +// ), +// }; +// } + +// itinerary.route = route; + +// // delete itinerary.tracking_pixel; +// // delete itinerary.facilitated_booking_available; +// // delete itinerary.pnr_count; +// // delete itinerary.has_airport_change; +// // delete itinerary.technical_stops; +// // delete itinerary.throw_away_ticketing; +// // delete itinerary.hidden_city_ticketing; +// // delete itinerary.virtual_interlining; +// // delete itinerary.transfers; +// // delete itinerary.booking_token; +// // // delete itinerary.deep_link; +// // delete itinerary.local_arrival; +// // delete itinerary.local_departure; +// // delete itinerary.utc_arrival; +// // delete itinerary.utc_departure; + +// return itinerary; +// }; + /** * Extract connection data for this array of flights if any connection * @param {*} flights array of flights * @returns an array of flight connections */ -const extractConnections = (flights) => { +const extractConnections = (routes: KiwiRoute[]) => { const connections = []; - if (flights.length > 1) connections.push(flights[0].cityTo); - if (flights.length > 2) connections.push(flights[1].cityTo); + if (routes.length > 1) connections.push(routes[0].cityTo); + if (routes.length > 2) connections.push(routes[1].cityTo); return connections; }; @@ -212,7 +328,7 @@ const extractConnections = (flights) => { * @param {*} d time received from Kiwi API * @returns correct local time */ -const formatTime = (d) => { +const formatTime = (d: string) => { // time received from Kiwi are supposed to ISO format local or ISO format UTC but they all end up 'Z' - 2022-10-24T21:10:00.000Z - like if it was London time, but it's not. To display a local time (which is what we want), we need to remove the Z part. const dWithoutZ = d.split('Z')[0]; const result = DateTime.fromISO(dWithoutZ).toLocaleString( @@ -227,7 +343,9 @@ const formatTime = (d) => { * @param {*} params the params to prepare for Axios * @returns a URLSearchParams object representing all the params necesarry for Axios call. */ -const prepareAxiosParams = (params) => { +// FIXME: +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const prepareWeekendParamsForAxios = (params: any): URLSearchParams => { const urlSearchParams = new URLSearchParams(); for (const param in params) { @@ -247,12 +365,15 @@ const prepareAxiosParams = (params) => { * @param {*} params input params * @returns */ -const prepareDefaultAPIParams = (params) => { +// FIXME: not sure if still necessary since in getFlights we put default params if needed. In any case, should be in API-related helper fonction, or this apiHelper module should be renamed to kiwi slmething ... +const prepareDefaultAPIParams = ( + params: RegularFlightsParams | WeekendFlightsParams +) => { return { ...params, - adults: params.adults || 1, - children: params.children || 0, - infants: params.infants || 0, + adults: params.adults || '1', + children: params.children || '0', + infants: params.infants || '0', }; }; @@ -274,7 +395,10 @@ const prepareDefaultAPIParams = (params) => { } * @returns params preapred for Axios */ -const prepareSeveralOriginAPIParamsFromView = (params) => { +// FIXME: removed any + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const prepareSeveralOriginAPIParamsFromView = (params: any) => { const { departureDate, returnDate, origins } = params; const baseParams = { @@ -282,13 +406,14 @@ const prepareSeveralOriginAPIParamsFromView = (params) => { returnDate: DateTime.fromISO(returnDate).toFormat(`dd'/'LL'/'yyyy`), }; - const allOriginParams = origins.flyFrom.map((_, i) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const allOriginParams = origins.flyFrom.map((_: any, i: number) => { return { ...baseParams, origin: origins.flyFrom[i], - adults: origins.adults ? +origins.adults[i] : 1, - children: origins.children ? +origins.children[i] : 0, - infants: origins.infants ? +origins.infants[i] : 0, + adults: origins.adults ? origins.adults[i] : '1', + children: origins.children ? origins.children[i] : '0', + infants: origins.infants ? origins.infants[i] : '0', }; }); @@ -310,37 +435,56 @@ const prepareSeveralOriginAPIParamsFromView = (params) => { } * @returns */ -const prepareSeveralOriginAPIParams = (params) => { +const prepareSeveralOriginAPIParams = ( + params: RegularFlightsParams +): RegularFlightsParams[] => { const origins = params.origin.split(','); const adults = params.adults ? params.adults.split(',') - : new Array(origins.length).fill(1); + : new Array(origins.length).fill('1'); const children = params.children ? params.children.split(',') - : new Array(origins.length).fill(0); + : new Array(origins.length).fill('0'); const infants = params.infants ? params.infants.split(',') - : new Array(origins.length).fill(0); + : new Array(origins.length).fill('0'); return origins.map((origin, i) => { return { ...params, origin, - adults: +adults[i], - children: +children[i], - infants: +infants[i], + adults: adults[i], + children: children[i], + infants: infants[i], }; }); }; +const getMapPassengersPerOrigin = ( + allOriginsParams: RegularFlightsParams[] +): Map => { + //FIXME: decide if RegularFlightsParams.adults can be undefined or no (optional or no). We can probably refactor and add the default number of adults, children and infants in the validation software. + return new Map( + allOriginsParams.map((oneOriginParam) => [ + oneOriginParam.origin, + +(oneOriginParam.adults ?? 1) + + +(oneOriginParam.children ?? 0) + + +(oneOriginParam.infants ?? 0), + ]) + ); + // return new Map(); +}; + export = { - cleanItineraryData, + convertKiwiItineraryToItinerary, extractConnections, filterDestinationCities, isCommonDestination, - prepareItineraryData, - prepareAxiosParams, + prepareDestinationData, + prepareWeekendParamsForAxios, prepareDefaultAPIParams, prepareSeveralOriginAPIParams, prepareSeveralOriginAPIParamsFromView, + groupByDestination, + getMapPassengersPerOrigin, }; diff --git a/src/utils/apiHelper.test.ts b/src/utils/apiHelper.unit.test.ts similarity index 52% rename from src/utils/apiHelper.test.ts rename to src/utils/apiHelper.unit.test.ts index d08aa86..e60df0b 100644 --- a/src/utils/apiHelper.test.ts +++ b/src/utils/apiHelper.unit.test.ts @@ -1,33 +1,45 @@ import helper from './apiHelper'; import apiOneWayAnswer from '../datasets/fixtures/apiOneWayAnswer.json'; import apiReturnAnswer from '../datasets/fixtures/apiReturnAnswer.json'; +import { + Itinerary, + KiwiItinerary, + KiwiRoute, + RegularFlightsParams, +} from '../common/types'; +import { + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD, + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU, + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD, +} from './fixtures'; describe('API Helper', function () { - describe('cleanItineraryData', function () { + describe('convertKiwiItineraryToItinerary', function () { test('should remove data from one-way itinerary', function () { - const itinerary = apiOneWayAnswer.data[0]; - const cleaned = helper.cleanItineraryData(itinerary); - expect(cleaned).not.toHaveProperty('countryFrom'); + const itinerary: KiwiItinerary = apiOneWayAnswer.data[0]; + const converted: Itinerary = + helper.convertKiwiItineraryToItinerary(itinerary); + expect(converted).not.toHaveProperty('countryFrom'); }); test('should remove data from return itinerary', function () { const itinerary = apiReturnAnswer.data[0]; - const cleaned = helper.cleanItineraryData(itinerary); + const cleaned = helper.convertKiwiItineraryToItinerary(itinerary); expect(cleaned).not.toHaveProperty('countryFrom'); }); - test('should normalize data from one-way itinerary', function () { + test('should add data from one-way itinerary', function () { const itinerary = apiOneWayAnswer.data[0]; - const cleaned = helper.cleanItineraryData(itinerary); - expect(cleaned).toHaveProperty('route.oneway'); - expect(cleaned).not.toHaveProperty('route.return'); + const cleaned = helper.convertKiwiItineraryToItinerary(itinerary); + expect(cleaned).toHaveProperty('onewayRoute'); + expect(cleaned).not.toHaveProperty('returnRoute'); }); - test('should normalize data from return itinerary', function () { + test('should add data from return itinerary', function () { const itinerary = apiReturnAnswer.data[0]; - const cleaned = helper.cleanItineraryData(itinerary); - expect(cleaned).toHaveProperty('route.oneway'); - expect(cleaned).toHaveProperty('route.return'); + const cleaned = helper.convertKiwiItineraryToItinerary(itinerary); + expect(cleaned).toHaveProperty('onewayRoute'); + expect(cleaned).toHaveProperty('returnRoute'); }); }); @@ -37,7 +49,7 @@ describe('API Helper', function () { { cityFrom: 'CDG', cityTo: 'MAD' }, { cityFrom: 'MAD', cityTo: 'UIO' }, ]; - const connections = helper.extractConnections(flights); + const connections = helper.extractConnections(flights as KiwiRoute[]); expect(connections[0]).toBe('MAD'); }); @@ -47,78 +59,141 @@ describe('API Helper', function () { { cityFrom: 'MAD', cityTo: 'UIO' }, { cityFrom: 'UIO', cityTo: 'GPS' }, ]; - const connections = helper.extractConnections(flights); + const connections = helper.extractConnections(flights as KiwiRoute[]); expect(connections[0]).toBe('MAD'); expect(connections[1]).toBe('UIO'); }); }); - describe('prepareItineraryData', function () { - const itineraries = [ - { - cityFrom: 'Madrid', - flyFrom: 'MAD', - countryTo: { name: 'Spain' }, - cityCodeTo: 'IBZ', - cityTo: 'Ibiza', - price: 78, - distance: 600, - duration: { departure: 85, return: 85 }, - }, - { - cityFrom: 'Bordeaux', - flyFrom: 'BOD', - countryTo: { name: 'Spain' }, - cityCodeTo: 'IBZ', - cityTo: 'Ibiza', - price: 65, - distance: 800, - duration: { departure: 105, return: 105 }, - }, - { - cityFrom: 'Brussels', - flyFrom: 'BRU', - countryTo: { name: 'Spain' }, - cityCodeTo: 'IBZ', - cityTo: 'Ibiza', - price: 130, - distance: 1500, - duration: { departure: 135, return: 135 }, - }, - { - cityFrom: 'Brussels', - flyFrom: 'BRU', - countryTo: { name: 'Spain' }, - cityCodeTo: 'OPO', - cityTo: 'Oporto', - price: 130, - distance: 1500, - duration: { departure: 135, return: 135 }, - }, + describe('prepareDestinationData', function () { + const kiwiItineraries = [ + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD, ]; + const itineraries = kiwiItineraries.map( + helper.convertKiwiItineraryToItinerary + ); const mapPassengersPerOrigin = new Map(); mapPassengersPerOrigin.set('MAD', 1); mapPassengersPerOrigin.set('BRU', 2); mapPassengersPerOrigin.set('BOD', 2); + test('should exclude a flight that does not go to that destination', () => { - const itinerary = helper.prepareItineraryData( + const itinerary = helper.prepareDestinationData( 'Ibiza', itineraries, mapPassengersPerOrigin ); - expect(itinerary.countryTo).toBe('Spain'); + expect(itinerary.countryTo).toBe('Espagne'); expect(itinerary.cityCodeTo).toBe('IBZ'); - expect(itinerary.flights).toHaveLength(3); + expect(itinerary.itineraries).toHaveLength(3); }); + test('should compute all info about a set of flights', () => { - const itinerary = helper.prepareItineraryData( + const itinerary = helper.prepareDestinationData( 'Ibiza', itineraries, mapPassengersPerOrigin ); - expect(itinerary.price).toBe(78 + 65 + 130); + expect(itinerary.price).toBe( + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD[0].price + + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD[0].price + + COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU[0].price + ); + }); + }); + + describe('getMapPassengersPerOrigin', function () { + test('builds a map of total passengers per origin', () => { + const params: RegularFlightsParams[] = [ + { + origin: 'MAD', + departureDate: '25/03/2023', + returnDate: '28/03/2023', + adults: '1', + children: '0', + infants: '0', + }, + { + origin: 'BCN', + departureDate: '25/03/2023', + returnDate: '28/03/2023', + adults: '3', + children: '2', + infants: '1', + }, + { + origin: 'CDG', + departureDate: '25/03/2023', + returnDate: '28/03/2023', + adults: '0', + children: '0', + infants: '0', + }, + ]; + const received = helper.getMapPassengersPerOrigin(params); + + expect(received.get('MAD')).toBe(1); + expect(received.get('BCN')).toBe(6); + expect(received.get('CDG')).toBe(0); + }); + }); + + describe('groupByDestination', function () { + test('groups itineraries by destination city', () => { + // const itineraries : (Partial)[] = [ + // {cityTo:'Milan',cityFrom:'Madrid'}, + // {cityTo:'Milan',cityFrom:'Bordeaux'}, + // {cityTo:'Milan',cityFrom:'Bruxelles'}, + // {cityTo:'Ibiza',cityFrom:'Bordeaux'}, + // {cityTo:'Ibiza',cityFrom:'Madrid'}, + // {cityTo:'Prague',cityFrom:'Bordeaux'} + // ]; + const kiwiItineraries = [ + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD, + ]; + const itineraries = kiwiItineraries.map( + helper.convertKiwiItineraryToItinerary + ); + + const receivedDestinations = helper.groupByDestination(itineraries); + const expectedDestinationCities = [ + 'Ibiza', + 'Lisbonne', + 'Genève', + 'Oslo', + 'Séville', + 'Bilbao', + 'Prague', + ]; + expect(Array.from(receivedDestinations.keys())).toEqual( + expect.arrayContaining(expectedDestinationCities) + ); + + const expectedCityCodesForIbiza = ['MAD', 'BRU', 'BOD']; + const ibizaItineraries = receivedDestinations.get('Ibiza'); + expect(ibizaItineraries).toHaveLength(3); + expect(expectedCityCodesForIbiza).toContain( + ibizaItineraries[0].cityCodeFrom + ); + expect(expectedCityCodesForIbiza).toContain( + ibizaItineraries[1].cityCodeFrom + ); + expect(expectedCityCodesForIbiza).toContain( + ibizaItineraries[2].cityCodeFrom + ); + + const expectedCityCodesForOslo = ['BOD']; + const osloItineraries = receivedDestinations.get('Oslo'); + expect(osloItineraries).toHaveLength(1); + expect(expectedCityCodesForOslo).toContain( + osloItineraries[0].cityCodeFrom + ); }); }); @@ -163,7 +238,7 @@ describe('API Helper', function () { { cityCodeFrom: 'BOD', flyFrom: 'BOD' }, { cityCodeFrom: 'BRU', flyFrom: 'BRU' }, { cityCodeFrom: 'LON', flyFrom: 'LGW' }, - ]; + ] as Itinerary[]; test('returns true if all the origins are present as origins from the destination flights', () => { const origins = ['MAD', 'BOD', 'BRU']; @@ -186,10 +261,10 @@ describe('API Helper', function () { describe('prepareDefaultAPIParams', function () { test('should used user params when present', () => { const params = { - adults: 3, - children: 2, - infants: 3, - }; + adults: '3', + children: '2', + infants: '3', + } as RegularFlightsParams; const preparedParams = helper.prepareDefaultAPIParams(params); @@ -199,13 +274,13 @@ describe('API Helper', function () { }); test('should include default params when missing', () => { - const params = {}; + const params = {} as RegularFlightsParams; const preparedParams = helper.prepareDefaultAPIParams(params); - expect(preparedParams.adults).toBe(1); - expect(preparedParams.children).toBe(0); - expect(preparedParams.infants).toBe(0); + expect(preparedParams.adults).toBe('1'); + expect(preparedParams.children).toBe('0'); + expect(preparedParams.infants).toBe('0'); }); }); @@ -229,9 +304,9 @@ describe('API Helper', function () { const preparedParams = helper.prepareSeveralOriginAPIParamsFromView(params); - expect(preparedParams[0].adults).toBe(1); - expect(preparedParams[0].children).toBe(0); - expect(preparedParams[0].infants).toBe(0); + expect(preparedParams[0].adults).toBe('1'); + expect(preparedParams[0].children).toBe('0'); + expect(preparedParams[0].infants).toBe('0'); }); test('should return the correct number of adults, children and infants for each origin when specified', () => { @@ -247,16 +322,18 @@ describe('API Helper', function () { const preparedParams = helper.prepareSeveralOriginAPIParamsFromView(params); - expect(preparedParams[0].adults).toBe(2); - expect(preparedParams[1].children).toBe(1); - expect(preparedParams[2].infants).toBe(1); + expect(preparedParams[0].adults).toBe('2'); + expect(preparedParams[1].children).toBe('1'); + expect(preparedParams[2].infants).toBe('1'); }); }); describe('prepareSeveralOriginAPIParams', function () { test('should return an array of same lengh than the number of origins', () => { - const params = { + const params: RegularFlightsParams = { origin: 'MAD,CRL,BRU,SXF,JFK', + departureDate: '25/03/2023', + returnDate: '28/03/2023', }; const preparedParams = helper.prepareSeveralOriginAPIParams(params); @@ -267,27 +344,31 @@ describe('API Helper', function () { test('should return 1 adult, 0 children, 0 infant for each origin if nothing specified', () => { const params = { origin: 'MAD,CRL,BRU,SXF,JFK', + departureDate: '25/03/2023', + returnDate: '28/03/2023', }; const preparedParams = helper.prepareSeveralOriginAPIParams(params); - expect(preparedParams[0].adults).toBe(1); - expect(preparedParams[0].children).toBe(0); - expect(preparedParams[0].infants).toBe(0); + expect(preparedParams[0].adults).toBe('1'); + expect(preparedParams[0].children).toBe('0'); + expect(preparedParams[0].infants).toBe('0'); }); test('should return the correct number of adults, children and infants for each origin when specified', () => { const params = { origin: 'MAD,CRL,BRU,SXF,JFK', + departureDate: '25/03/2023', + returnDate: '28/03/2023', adults: '1,2,1,3,1', children: '0,0,3,1,1', }; const preparedParams = helper.prepareSeveralOriginAPIParams(params); - expect(preparedParams[1].adults).toBe(2); - expect(preparedParams[2].children).toBe(3); - expect(preparedParams[0].infants).toBe(0); + expect(preparedParams[1].adults).toBe('2'); + expect(preparedParams[2].children).toBe('3'); + expect(preparedParams[0].infants).toBe('0'); }); }); }); diff --git a/src/utils/appError.ts b/src/utils/appError.ts index 02b408d..51ba0e4 100644 --- a/src/utils/appError.ts +++ b/src/utils/appError.ts @@ -1,8 +1,8 @@ class AppError extends Error { - public statusCode: string; + public statusCode: number; public status: string; public isOperational: boolean; - constructor(message, statusCode) { + constructor(message: string, statusCode: number) { super(message); this.statusCode = statusCode; this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; diff --git a/src/utils/catchAsync.ts b/src/utils/catchAsync.ts index 2ef32b2..97f2818 100644 --- a/src/utils/catchAsync.ts +++ b/src/utils/catchAsync.ts @@ -1,7 +1,9 @@ import AppError from './appError'; +import axios, { AxiosError } from 'axios'; +import { Request, Response, NextFunction } from 'express-serve-static-core'; -const handleKiwiError = (err) => { - if (err.response.status === 422 || err.response.status === 400) { +const handleKiwiError = (err: AxiosError) => { + if (err.response?.status === 422 || err.response?.status === 400) { // an error occurred on 3rd party Kiwi because of some input query parameters fed to to Pulpito API client (if error 422) or because some parameters for KIWI are missing (error 400) return new AppError( `Error in 3rd party API : ${err.response.data.error}`, @@ -15,8 +17,8 @@ const handleKiwiError = (err) => { } }; -const catchKiwiError = (err, next) => { - if (err.response) { +const catchKiwiError = (err: Error | AxiosError, next: NextFunction) => { + if (axios.isAxiosError(err) && err.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx return next(handleKiwiError(err)); @@ -38,9 +40,11 @@ const catchKiwiError = (err, next) => { * @param {*} fn function to be try-catched * @returns the same function, but now protected by try-catch */ -export const catchAsync = (fn) => { - return (req, res, next) => { - return fn(req, res, next).catch((err) => { +export const catchAsync = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) => { + return (req: Request, res: Response, next: NextFunction) => { + return fn(req, res, next).catch((err: Error) => { console.error(err); next(err); }); @@ -53,8 +57,10 @@ export const catchAsync = (fn) => { * @param {*} fn function to be try-catched * @returns a function protected by try-catch */ -export const catchAsyncKiwi = (fn) => { - return (req, res, next) => { - return fn(req, res, next).catch((err) => catchKiwiError(err, next)); +export const catchAsyncKiwi = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) => { + return (req: Request, res: Response, next: NextFunction) => { + return fn(req, res, next).catch((err: Error) => catchKiwiError(err, next)); }; }; diff --git a/src/utils/email.ts b/src/utils/email.ts index 6baac1a..8f6ac98 100644 --- a/src/utils/email.ts +++ b/src/utils/email.ts @@ -1,13 +1,16 @@ +import { Request } from 'express-serve-static-core'; import nodemailer from 'nodemailer'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; -const transport = nodemailer.createTransport({ +const options: SMTPTransport.Options = { host: process.env.EMAIL_HOST, - port: process.env.EMAIL_PORT, + port: +process.env.EMAIL_PORT, auth: { user: process.env.EMAIL_USERNAME, pass: process.env.EMAIL_PASSWORD, }, -}); +}; +const transport = nodemailer.createTransport(options); /** * Sends the password reset token email @@ -15,7 +18,11 @@ const transport = nodemailer.createTransport({ * @param {*} email the email to send to * @param {*} resetToken the reset token generated */ -const sendPasswordResetTokenEmail = async (req, email, resetToken) => { +const sendPasswordResetTokenEmail = async ( + req: Request, + email: string, + resetToken: string +) => { // try with mailtrap.io const resetURL = `${req.protocol}://${req.get( 'host' @@ -34,7 +41,11 @@ const sendPasswordResetTokenEmail = async (req, email, resetToken) => { * @param {*} options email options * @returns true if email was successfully sent */ -const sendMail = async (options) => { +const sendMail = async (options: { + email: string; + subject: string; + message: string; +}) => { return await transport.sendMail({ from: `"Pulpito 🐙" `, to: options.email, diff --git a/src/utils/fixtures.ts b/src/utils/fixtures.ts index 66a1b3b..2a93146 100644 --- a/src/utils/fixtures.ts +++ b/src/utils/fixtures.ts @@ -90,309 +90,136 @@ const COMMON_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN = { const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ { - id: '25c318ff4afb0000899650a2_0', flyFrom: 'CDG', flyTo: 'LTN', cityFrom: 'Paris', cityCodeFrom: 'PAR', cityTo: 'Londres', cityCodeTo: 'LON', - countryFrom: { - code: 'FR', - name: 'France', - }, countryTo: { code: 'GB', name: 'Royaume-Uni', }, - type_flights: ['deprecated'], - nightsInDest: null, - quality: 70.66661, distance: 380, duration: { departure: 4800, return: 0, total: 4800, }, - price: 48, - conversion: { - EUR: 48, - }, fare: { adults: 48, children: 48, infants: 48, }, - bags_price: { - 1: 52.5, - 2: 105, - }, - baglimit: { - hand_height: 36, - hand_length: 45, - hand_weight: 15, - hand_width: 20, - hold_dimensions_sum: 275, - hold_height: 90, - hold_length: 135, - hold_weight: 15, - hold_width: 50, - }, - availability: { - seats: 1, - }, - routes: [['CDG', 'LTN']], - airlines: ['U2'], + price: 48, route: [ { - id: '25c318ff4afb0000899650a2_0', - combination_id: '25c318ff4afb0000899650a2', flyFrom: 'CDG', flyTo: 'LTN', cityFrom: 'Paris', cityCodeFrom: 'PAR', cityTo: 'Londres', cityCodeTo: 'LON', - airline: 'U2', - flight_no: 2442, - operating_carrier: 'U2', - operating_flight_no: '2442', - fare_basis: '', - fare_category: 'M', - fare_classes: '', - fare_family: '', return: 0, - bags_recheck_required: false, - vi_connection: false, - guarantee: false, - last_seen: '2022-05-26T11:58:58.000Z', - refresh_timestamp: '2022-05-26T11:58:58.000Z', - equipment: null, - vehicle_type: 'aircraft', local_arrival: '2022-07-22T22:15:00.000Z', utc_arrival: '2022-07-22T21:15:00.000Z', local_departure: '2022-07-22T21:55:00.000Z', utc_departure: '2022-07-22T19:55:00.000Z', }, ], - booking_token: - 'DDjI6eko_AkGSHM0iC6Lv0B2oyWhnQVU0ZJrPOMdrGKnjA_aY0wbT-IgdILXfesPcoAhEMiI1mlV5jDGQPOzkZ2_bCzr_iKDJhkFfNZ81kWogj0653ONNMnpcWGJ2r07zbhRsSW4wu2NsrzAsuymCcGjkWVWU0laX8OQZBJaQIn5_RgU12DQOtLmhTKub6BQk6VQmAceccZ-c9uCQa0GEVKs14NyKSVUiDuYQRsGMEo2Z72rLwvBRzSpxurTGxRbSr-0ClQJUYXEfF4-R7csyInrCGHofBrVrrO1_7s62NweUHKtq5HHwbToA6-95SIjYQkQtRyv3Jsv1c-3ceqDiJYeNS5A3q6Tmpnh2qn8-uuA0s7ynpvowlwYp8N3OBdh7g9EH_3YLqOLUb2hLZeNr9wA4l4G0xNVuTwvnRulzUUiGP7D9Dz9AS5HG-YRX7Ic82uvpwY39iTKxNw_O3MIlK7UM6L9P8a2fdpF1ZylF01bXsKZ-UVLNGnsYmll24dWH', + deep_link: 'https://www.kiwi.com/deep?affilid=nicolasdaudintripsy¤cy=EUR&flightsId=25c318ff4afb0000899650a2_0&from=CDG&lang=fr&passengers=1&to=LTN&booking_token=DDjI6eko_AkGSHM0iC6Lv0B2oyWhnQVU0ZJrPOMdrGKnjA_aY0wbT-IgdILXfesPcoAhEMiI1mlV5jDGQPOzkZ2_bCzr_iKDJhkFfNZ81kWogj0653ONNMnpcWGJ2r07zbhRsSW4wu2NsrzAsuymCcGjkWVWU0laX8OQZBJaQIn5_RgU12DQOtLmhTKub6BQk6VQmAceccZ-c9uCQa0GEVKs14NyKSVUiDuYQRsGMEo2Z72rLwvBRzSpxurTGxRbSr-0ClQJUYXEfF4-R7csyInrCGHofBrVrrO1_7s62NweUHKtq5HHwbToA6-95SIjYQkQtRyv3Jsv1c-3ceqDiJYeNS5A3q6Tmpnh2qn8-uuA0s7ynpvowlwYp8N3OBdh7g9EH_3YLqOLUb2hLZeNr9wA4l4G0xNVuTwvnRulzUUiGP7D9Dz9AS5HG-YRX7Ic82uvpwY39iTKxNw_O3MIlK7UM6L9P8a2fdpF1ZylF01bXsKZ-UVLNGnsYmll24dWH', - facilitated_booking_available: true, - pnr_count: 1, - has_airport_change: false, - technical_stops: 0, - throw_away_ticketing: false, - hidden_city_ticketing: false, - virtual_interlining: false, - transfers: [], local_arrival: '2022-07-22T22:15:00.000Z', utc_arrival: '2022-07-22T21:15:00.000Z', local_departure: '2022-07-22T21:55:00.000Z', utc_departure: '2022-07-22T19:55:00.000Z', }, { - id: '25c31f2d4afb0000eefb56c9_0', flyFrom: 'CDG', flyTo: 'BRS', cityFrom: 'Paris', cityCodeFrom: 'PAR', cityTo: 'Bristol', cityCodeTo: 'BRS', - countryFrom: { - code: 'FR', - name: 'France', - }, countryTo: { code: 'GB', name: 'Royaume-Uni', }, - type_flights: ['deprecated'], - nightsInDest: null, - quality: 70.999945, distance: 458.75, duration: { departure: 4500, return: 0, total: 4500, }, - price: 49, - conversion: { - EUR: 49, - }, fare: { adults: 49, children: 49, infants: 49, }, - bags_price: { - 1: 52.5, - 2: 105, - }, - baglimit: { - hand_height: 36, - hand_length: 45, - hand_weight: 15, - hand_width: 20, - hold_dimensions_sum: 275, - hold_height: 90, - hold_length: 135, - hold_weight: 15, - hold_width: 50, - }, - availability: { - seats: 3, - }, - routes: [['CDG', 'BRS']], - airlines: ['U2'], + price: 49, route: [ { - id: '25c31f2d4afb0000eefb56c9_0', - combination_id: '25c31f2d4afb0000eefb56c9', flyFrom: 'CDG', flyTo: 'BRS', cityFrom: 'Paris', cityCodeFrom: 'PAR', cityTo: 'Bristol', cityCodeTo: 'BRS', - airline: 'U2', - flight_no: 6222, - operating_carrier: 'U2', - operating_flight_no: '6222', - fare_basis: '', - fare_category: 'M', - fare_classes: '', - fare_family: '', return: 0, - bags_recheck_required: false, - vi_connection: false, - guarantee: false, - last_seen: '2022-05-26T11:04:05.000Z', - refresh_timestamp: '2022-05-26T11:04:05.000Z', - equipment: null, - vehicle_type: 'aircraft', local_arrival: '2022-07-22T10:45:00.000Z', utc_arrival: '2022-07-22T09:45:00.000Z', local_departure: '2022-07-22T10:30:00.000Z', utc_departure: '2022-07-22T08:30:00.000Z', }, ], - booking_token: - 'Dk293Pd8pTA1rPs1Ci_81rlaIfGVclBZbVgI-t9lf_fKqlROP67GE-aq6XlasKyIltG_YPGnX0HK7_jRe6gDWmEHe3ohqBHfdiB8wy_R7M5HzvM9tD0jGLKUtsleeAMnqv_cvvp75o39R2V85SZELiQzhvwI7rB9ck7jfEeiYthePn0r38CNpyq78LsoAJFFL1DUsYummXqhNv65LaDiJMnJBpvnUTkr4CDrNK0q6uOaaKpG2xLVi6ukQ7bhBEzt3hgFnrXdmCB3SwnrMKGZIJnBEl3HN3xgafCKPy4sM5tIaZaihJusC7CSK9i9E2cDcUneS94EzJemiCSEAYIjSlnwgWwpFGJjh2uoBKWZ3_ADqzPPrht7R0ogYmSyno3EemuElAFCmEiWXMT9Ug1vr_cBknS_4Z9hTWy9ie6GooDOplgwvXZWBXhIPi4Tjq_r6AqusGym8cb0MxLescu-qdekKtHQa-oDYuTg7Shgv3LwKGSrsZmwzyX20uQkwrmPH', deep_link: 'https://www.kiwi.com/deep?affilid=nicolasdaudintripsy¤cy=EUR&flightsId=25c31f2d4afb0000eefb56c9_0&from=CDG&lang=fr&passengers=1&to=BRS&booking_token=Dk293Pd8pTA1rPs1Ci_81rlaIfGVclBZbVgI-t9lf_fKqlROP67GE-aq6XlasKyIltG_YPGnX0HK7_jRe6gDWmEHe3ohqBHfdiB8wy_R7M5HzvM9tD0jGLKUtsleeAMnqv_cvvp75o39R2V85SZELiQzhvwI7rB9ck7jfEeiYthePn0r38CNpyq78LsoAJFFL1DUsYummXqhNv65LaDiJMnJBpvnUTkr4CDrNK0q6uOaaKpG2xLVi6ukQ7bhBEzt3hgFnrXdmCB3SwnrMKGZIJnBEl3HN3xgafCKPy4sM5tIaZaihJusC7CSK9i9E2cDcUneS94EzJemiCSEAYIjSlnwgWwpFGJjh2uoBKWZ3_ADqzPPrht7R0ogYmSyno3EemuElAFCmEiWXMT9Ug1vr_cBknS_4Z9hTWy9ie6GooDOplgwvXZWBXhIPi4Tjq_r6AqusGym8cb0MxLescu-qdekKtHQa-oDYuTg7Shgv3LwKGSrsZmwzyX20uQkwrmPH', - facilitated_booking_available: true, - pnr_count: 1, - has_airport_change: false, - technical_stops: 0, - throw_away_ticketing: false, - hidden_city_ticketing: false, - virtual_interlining: false, - transfers: [], local_arrival: '2022-07-22T10:45:00.000Z', utc_arrival: '2022-07-22T09:45:00.000Z', local_departure: '2022-07-22T10:30:00.000Z', utc_departure: '2022-07-22T08:30:00.000Z', }, { - id: '25c322f54afb000009eb73e3_0', flyFrom: 'CDG', flyTo: 'LGW', cityFrom: 'Paris', cityCodeFrom: 'PAR', cityTo: 'Londres', cityCodeTo: 'LON', - countryFrom: { - code: 'FR', - name: 'France', - }, countryTo: { code: 'GB', name: 'Royaume-Uni', }, - type_flights: ['deprecated'], - nightsInDest: null, - quality: 73.33328, distance: 308.04, duration: { departure: 4200, return: 0, total: 4200, }, - price: 52, - conversion: { - EUR: 52, - }, fare: { adults: 52, children: 52, infants: 52, }, - bags_price: { - 1: 52.5, - 2: 105, - }, - baglimit: { - hand_height: 36, - hand_length: 45, - hand_weight: 15, - hand_width: 20, - hold_dimensions_sum: 275, - hold_height: 90, - hold_length: 135, - hold_weight: 15, - hold_width: 50, - }, - availability: { - seats: 8, - }, - routes: [['CDG', 'LGW']], - airlines: ['U2'], + price: 52, route: [ { - id: '25c322f54afb000009eb73e3_0', - combination_id: '25c322f54afb000009eb73e3', flyFrom: 'CDG', flyTo: 'LGW', cityFrom: 'Paris', cityCodeFrom: 'PAR', cityTo: 'Londres', cityCodeTo: 'LON', - airline: 'U2', - flight_no: 8322, - operating_carrier: 'EC', - operating_flight_no: '', - fare_basis: '', - fare_category: 'M', - fare_classes: '', - fare_family: '', return: 0, - bags_recheck_required: false, - vi_connection: false, - guarantee: false, - last_seen: '2022-05-26T10:46:15.000Z', - refresh_timestamp: '2022-05-26T10:46:15.000Z', - equipment: null, - vehicle_type: 'aircraft', local_arrival: '2022-07-22T07:25:00.000Z', utc_arrival: '2022-07-22T06:25:00.000Z', local_departure: '2022-07-22T07:15:00.000Z', utc_departure: '2022-07-22T05:15:00.000Z', }, ], - booking_token: - 'DY090yVYLOrwiA5WBU4AZVipyDIhMgdWrcCcOLtOzLYyJunqKvPq-Yxsc5XOoaERfal1qqS5Z7uQ_TQSuaqpm-vWhsbNJoQuKl4PFikJ-EmITgzVYQ1063mhr5HqFR5LCkTqrKCT9OfbV6w9nhLWnOe36JpOGy3SULOirz5Hrc60Ir9_6fLayltsKc8Nu581ftZLFkxpbTgTeax2y2NWd_QASMy7Xskfep1B1n0unI__GL9u_q9cuiqsPZE69oBFZQjzqtIMYbf18SpkX5vo7dNZlav5622j_zyGh0U5KiQVneLZfNpsBuRGgFHt6Oyj4ikCUzhbQYjthACoqDnrzM7KJuT6xhZj9ccQckndszLMnryEUDIZ2hb2rUAxHOrlpv7YNy31M4DJKjOKd1202Z7zUYhp_VHC-xvj8K4j4HR59afCRcf0TMy2sBIaTzMnwy7tCN6M4cJCYS7qaLSMJlhwZyup7cj0tx83KfFrS7C37Vn7OwJApg0f6IbzoO5dBm5yMLsOyaae1gGI6bqm-tw==', deep_link: 'https://www.kiwi.com/deep?affilid=nicolasdaudintripsy¤cy=EUR&flightsId=25c322f54afb000009eb73e3_0&from=CDG&lang=fr&passengers=1&to=LGW&booking_token=DY090yVYLOrwiA5WBU4AZVipyDIhMgdWrcCcOLtOzLYyJunqKvPq-Yxsc5XOoaERfal1qqS5Z7uQ_TQSuaqpm-vWhsbNJoQuKl4PFikJ-EmITgzVYQ1063mhr5HqFR5LCkTqrKCT9OfbV6w9nhLWnOe36JpOGy3SULOirz5Hrc60Ir9_6fLayltsKc8Nu581ftZLFkxpbTgTeax2y2NWd_QASMy7Xskfep1B1n0unI__GL9u_q9cuiqsPZE69oBFZQjzqtIMYbf18SpkX5vo7dNZlav5622j_zyGh0U5KiQVneLZfNpsBuRGgFHt6Oyj4ikCUzhbQYjthACoqDnrzM7KJuT6xhZj9ccQckndszLMnryEUDIZ2hb2rUAxHOrlpv7YNy31M4DJKjOKd1202Z7zUYhp_VHC-xvj8K4j4HR59afCRcf0TMy2sBIaTzMnwy7tCN6M4cJCYS7qaLSMJlhwZyup7cj0tx83KfFrS7C37Vn7OwJApg0f6IbzoO5dBm5yMLsOyaae1gGI6bqm-tw==', - facilitated_booking_available: true, - pnr_count: 1, - has_airport_change: false, - technical_stops: 0, - throw_away_ticketing: false, - hidden_city_ticketing: false, - virtual_interlining: false, - transfers: [], local_arrival: '2022-07-22T07:25:00.000Z', utc_arrival: '2022-07-22T06:25:00.000Z', local_departure: '2022-07-22T07:15:00.000Z', @@ -420,6 +247,7 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ }, type_flights: ['deprecated'], nightsInDest: 2, + quality: 381.3329, distance: 459.95, duration: { @@ -454,9 +282,7 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ personal_item_weight: 10, personal_item_width: 20, }, - availability: { - seats: null, - }, + availability: {}, routes: [ ['MAD', 'IBZ'], ['IBZ', 'MAD'], @@ -535,7 +361,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T21:50:00.000Z', utc_arrival: '2022-12-09T20:50:00.000Z', local_departure: '2022-12-09T20:40:00.000Z', @@ -622,7 +447,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ guarantee: false, last_seen: '2022-06-07T21:43:01.000Z', refresh_timestamp: '2022-06-07T21:43:01.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T10:25:00.000Z', utc_arrival: '2022-12-09T10:25:00.000Z', @@ -652,7 +476,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ guarantee: false, last_seen: '2022-06-07T22:35:58.000Z', refresh_timestamp: '2022-06-07T22:35:58.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T14:20:00.000Z', utc_arrival: '2022-12-11T13:20:00.000Z', @@ -671,7 +494,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T10:25:00.000Z', utc_arrival: '2022-12-09T10:25:00.000Z', local_departure: '2022-12-09T10:00:00.000Z', @@ -758,7 +580,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ guarantee: false, last_seen: '2022-06-07T21:20:04.000Z', refresh_timestamp: '2022-06-07T21:20:04.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T12:00:00.000Z', utc_arrival: '2022-12-09T11:00:00.000Z', @@ -788,7 +609,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ guarantee: false, last_seen: '2022-06-08T09:03:29.000Z', refresh_timestamp: '2022-06-08T09:03:29.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T09:15:00.000Z', utc_arrival: '2022-12-11T08:15:00.000Z', @@ -807,7 +627,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T12:00:00.000Z', utc_arrival: '2022-12-09T11:00:00.000Z', local_departure: '2022-12-09T10:00:00.000Z', @@ -869,9 +688,7 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ personal_item_weight: 10, personal_item_width: 20, }, - availability: { - seats: null, - }, + availability: {}, routes: [ ['BOD', 'IBZ'], ['IBZ', 'BOD'], @@ -901,7 +718,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: false, last_seen: '2022-06-07T16:56:22.000Z', refresh_timestamp: '1970-01-01T00:00:00.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T14:45:00.000Z', utc_arrival: '2022-12-09T13:45:00.000Z', @@ -931,7 +747,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: true, last_seen: '2022-06-08T07:10:48.000Z', refresh_timestamp: '1970-01-01T00:00:00.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T19:00:00.000Z', utc_arrival: '2022-12-09T18:00:00.000Z', @@ -961,7 +776,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: false, last_seen: '2022-06-08T07:42:25.000Z', refresh_timestamp: '1970-01-01T00:00:00.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T09:40:00.000Z', utc_arrival: '2022-12-11T08:40:00.000Z', @@ -991,7 +805,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: true, last_seen: '2022-06-07T19:22:16.000Z', refresh_timestamp: '1970-01-01T00:00:00.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T13:00:00.000Z', utc_arrival: '2022-12-11T12:00:00.000Z', @@ -1010,7 +823,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T19:00:00.000Z', utc_arrival: '2022-12-09T18:00:00.000Z', local_departure: '2022-12-09T13:35:00.000Z', @@ -1101,7 +913,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: false, last_seen: '2022-06-08T09:12:44.000Z', refresh_timestamp: '2022-06-08T09:12:44.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T10:00:00.000Z', utc_arrival: '2022-12-09T10:00:00.000Z', @@ -1131,7 +942,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: true, last_seen: '2022-06-08T10:24:27.000Z', refresh_timestamp: '2022-06-08T10:24:27.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T21:35:00.000Z', utc_arrival: '2022-12-09T20:35:00.000Z', @@ -1161,7 +971,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: false, last_seen: '2022-06-07T20:25:51.000Z', refresh_timestamp: '2022-06-07T20:25:51.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T15:10:00.000Z', utc_arrival: '2022-12-11T15:10:00.000Z', @@ -1191,7 +1000,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: true, last_seen: '2022-06-07T16:09:34.000Z', refresh_timestamp: '2022-06-07T16:09:34.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T22:30:00.000Z', utc_arrival: '2022-12-11T21:30:00.000Z', @@ -1210,7 +1018,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T21:35:00.000Z', utc_arrival: '2022-12-09T20:35:00.000Z', local_departure: '2022-12-09T09:20:00.000Z', @@ -1297,7 +1104,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: false, last_seen: '2022-06-08T08:35:12.000Z', refresh_timestamp: '2022-06-08T08:35:12.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T17:05:00.000Z', utc_arrival: '2022-12-09T16:05:00.000Z', @@ -1327,7 +1133,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: false, last_seen: '2022-06-08T08:10:16.000Z', refresh_timestamp: '2022-06-08T08:10:16.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T18:05:00.000Z', utc_arrival: '2022-12-11T17:05:00.000Z', @@ -1357,7 +1162,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ guarantee: true, last_seen: '2022-06-07T23:59:58.000Z', refresh_timestamp: '2022-06-07T23:59:58.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T21:05:00.000Z', utc_arrival: '2022-12-11T20:05:00.000Z', @@ -1376,7 +1180,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T17:05:00.000Z', utc_arrival: '2022-12-09T16:05:00.000Z', local_departure: '2022-12-09T15:25:00.000Z', @@ -1470,7 +1273,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ guarantee: false, last_seen: '2022-06-08T03:36:06.000Z', refresh_timestamp: '2022-06-08T03:36:06.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T12:25:00.000Z', utc_arrival: '2022-12-09T11:25:00.000Z', @@ -1500,7 +1302,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ guarantee: true, last_seen: '2022-06-08T07:10:48.000Z', refresh_timestamp: '1970-01-01T00:00:00.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T16:00:00.000Z', utc_arrival: '2022-12-09T15:00:00.000Z', @@ -1530,7 +1331,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ guarantee: false, last_seen: '2022-06-07T13:53:46.000Z', refresh_timestamp: '2022-06-07T13:53:46.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T16:40:00.000Z', utc_arrival: '2022-12-11T15:40:00.000Z', @@ -1560,7 +1360,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ guarantee: true, last_seen: '2022-06-08T10:02:27.000Z', refresh_timestamp: '2022-06-08T10:02:27.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T21:00:00.000Z', utc_arrival: '2022-12-11T20:00:00.000Z', @@ -1579,7 +1378,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T16:00:00.000Z', utc_arrival: '2022-12-09T15:00:00.000Z', local_departure: '2022-12-09T10:20:00.000Z', @@ -1778,7 +1576,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T21:25:00.000Z', utc_arrival: '2022-12-09T20:25:00.000Z', local_departure: '2022-12-09T15:15:00.000Z', @@ -1869,7 +1666,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ guarantee: false, last_seen: '2022-06-08T05:05:04.000Z', refresh_timestamp: '2022-06-08T05:05:04.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T19:45:00.000Z', utc_arrival: '2022-12-09T18:45:00.000Z', @@ -1899,7 +1695,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ guarantee: false, last_seen: '2022-06-07T17:58:46.000Z', refresh_timestamp: '2022-06-07T17:58:46.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T07:25:00.000Z', utc_arrival: '2022-12-11T06:25:00.000Z', @@ -1918,7 +1713,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T19:45:00.000Z', utc_arrival: '2022-12-09T18:45:00.000Z', local_departure: '2022-12-09T18:20:00.000Z', @@ -2013,7 +1807,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ guarantee: false, last_seen: '2022-06-08T06:59:26.000Z', refresh_timestamp: '2022-06-08T06:59:26.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T16:30:00.000Z', utc_arrival: '2022-12-09T15:30:00.000Z', @@ -2043,7 +1836,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ guarantee: false, last_seen: '2022-06-08T11:51:36.000Z', refresh_timestamp: '2022-06-08T11:51:36.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T16:40:00.000Z', utc_arrival: '2022-12-11T15:40:00.000Z', @@ -2062,7 +1854,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T16:30:00.000Z', utc_arrival: '2022-12-09T15:30:00.000Z', local_departure: '2022-12-09T15:20:00.000Z', @@ -2153,7 +1944,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ guarantee: false, last_seen: '2022-06-07T18:09:36.000Z', refresh_timestamp: '2022-06-07T18:09:36.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T15:45:00.000Z', utc_arrival: '2022-12-09T15:45:00.000Z', @@ -2183,7 +1973,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ guarantee: false, last_seen: '2022-06-08T05:26:01.000Z', refresh_timestamp: '2022-06-08T05:26:01.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T20:30:00.000Z', utc_arrival: '2022-12-11T19:30:00.000Z', @@ -2202,7 +1991,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T15:45:00.000Z', utc_arrival: '2022-12-09T15:45:00.000Z', local_departure: '2022-12-09T14:45:00.000Z', @@ -2293,7 +2081,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ guarantee: false, last_seen: '2022-06-08T07:35:09.000Z', refresh_timestamp: '2022-06-08T07:35:09.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-09T21:10:00.000Z', utc_arrival: '2022-12-09T20:10:00.000Z', @@ -2323,7 +2110,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ guarantee: false, last_seen: '2022-06-08T12:16:38.000Z', refresh_timestamp: '2022-06-08T12:16:38.000Z', - equipment: null, vehicle_type: 'aircraft', local_arrival: '2022-12-11T08:00:00.000Z', utc_arrival: '2022-12-11T07:00:00.000Z', @@ -2342,7 +2128,6 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS = [ throw_away_ticketing: false, hidden_city_ticketing: false, virtual_interlining: true, - transfers: [], local_arrival: '2022-12-09T21:10:00.000Z', utc_arrival: '2022-12-09T20:10:00.000Z', local_departure: '2022-12-09T19:10:00.000Z', diff --git a/src/utils/resultsHelper.ts b/src/utils/resultsHelper.ts index 1c19190..bba554a 100644 --- a/src/utils/resultsHelper.ts +++ b/src/utils/resultsHelper.ts @@ -1,30 +1,45 @@ +import { + DestinationWithItineraries, + FilterParams, + Itinerary, + RegularFlightsParams, + WeekendFlightsParams, +} from '../common/types'; +import cloneDeep from 'lodash.clonedeep'; + import { RESULTS_SEARCH_LIMIT, DEFAULT_SORT_FIELD } from '../config'; +import { Request } from 'express-serve-static-core'; +import { TypedRequestQueryWithFilter } from '../common/interfaces'; /** - * Checks if the flights for that itinerary have more than maxConnections - * @param {*} itinerary the itinerary + * Checks if the itineraries for that destination have more than maxConnections + * @param {*} destination the destination with its itineraries * @param {*} maxConnections max number of connections * @returns true if number of connections is less or equal to the allowed max number of connections */ -const filterByMaxConnections = (itinerary, maxConnections) => { +const filterByMaxConnections = (source: T, maxConnections: number) => { let nbConnections = 0; - if (itinerary.flights) { + + if (isDestinationWithItineraries(source)) { // if several origins - nbConnections = itinerary.flights.reduce( - (max, flight) => + nbConnections = source.itineraries.reduce( + (max, itinerary) => Math.max( max, - flight.route.oneway.connections.length, - flight.route.return.connections.length + itinerary.onewayRoute.connections.length, + itinerary.returnRoute?.connections.length ), 0 ); - } else { + } + + if (isItinerary(source)) { nbConnections = Math.max( - itinerary.route.oneway.connections.length, - itinerary.route.return.connections.length + source.onewayRoute.connections.length, + source.returnRoute?.connections.length ); } + return nbConnections <= maxConnections; }; @@ -35,26 +50,32 @@ const filterByMaxConnections = (itinerary, maxConnections) => { * @param {*} priceTo price upper limit * @returns true if flight prices are above priceFrom and below priceTo */ -const filterByPriceRange = (itinerary, priceFrom, priceTo) => { +const filterByPriceRange = ( + source: T, + priceFrom: number, + priceTo: number +) => { // flight.price has the total price for all passengers of that flight // we want to compare with the price per adult let minPrice = 20000; let maxPrice = 0; - if (itinerary.flights) { + if (isDestinationWithItineraries(source)) { // if several origins - minPrice = itinerary.flights.reduce( - (min, flight) => Math.min(min, flight.price), + minPrice = source.itineraries.reduce( + (min, itinerary) => Math.min(min, itinerary.price), minPrice ); - maxPrice = itinerary.flights.reduce( - (max, flight) => Math.max(max, flight.price), + maxPrice = source.itineraries.reduce( + (max, itinerary) => Math.max(max, itinerary.price), maxPrice ); - } else { + } + + if (isItinerary(source)) { // if one origin - minPrice = itinerary.price; - maxPrice = itinerary.price; + minPrice = source.price; + maxPrice = source.price; } return ( @@ -65,23 +86,26 @@ const filterByPriceRange = (itinerary, priceFrom, priceTo) => { /** * Returns an object with the necessary info to display the results and the search filters, like the filtered minimum price (requested by the user in the filterParams), the filtered maximum price (requested by the user in filterParams), the minimum possible itinerary price (out of all the itineraries), the maximum possible price (out of all the itineraries).... * Currently only works for search with several origins (getCommon) since it is the only one implemented in the webapp - * @param {*} itineraries + * @param {*} destinations * @param {*} filterParams * returns an object representing the filters used. Has the following properties: minPossiblePrice, maxPossiblePrice, priceFrom, priceTo, maxConnections */ -const getFilters = (itineraries, filterParams) => { - const minPossiblePrice = itineraries.reduce((min, itinerary) => { - const tempMin = itinerary.flights.reduce((min, flight) => { - // flight.price has the total price for all passengers of that flight +const getFilters = ( + destinations: DestinationWithItineraries[], + filterParams: FilterParams +) => { + const minPossiblePrice = destinations.reduce((min, destination) => { + const tempMin = destination.itineraries.reduce((min, itinerary) => { + // itinerary.price has the total price for all passengers of that flight // we want to compare with the price per adult - return Math.min(min, flight.price); + return Math.min(min, itinerary.price); }, 20000); return Math.min(tempMin, min); }, 20000); - const maxPossiblePrice = itineraries.reduce((max, itinerary) => { - const tempMax = itinerary.flights.reduce( - (max, flight) => Math.max(max, flight.price), + const maxPossiblePrice = destinations.reduce((max, destination) => { + const tempMax = destination.itineraries.reduce( + (max, itinerary) => Math.max(max, itinerary.price), 0 ); return Math.max(tempMax, max); @@ -105,12 +129,13 @@ const getFilters = (itineraries, filterParams) => { * @param {*} filterParams the filters to apply to the itineraries (maxConnections, priceFrom, ...) * @returns a copy of the itineraries after filtering */ -const filter = (itineraries, filterParams) => { - let result = JSON.parse(JSON.stringify(itineraries)); +const filter = (source: T[], filterParams: FilterParams) => { + const resultAfterClone = cloneDeep(source); + let result: Array = resultAfterClone; // filter by max number of connections allowed on each individual flight - if (filterParams.maxConnections) { - result = result.filter((itinerary) => { + if (filterParams.maxConnections !== undefined) { + result = result.filter((itinerary: T) => { const filtered = filterByMaxConnections( itinerary, filterParams.maxConnections @@ -121,7 +146,7 @@ const filter = (itineraries, filterParams) => { // filter by price range if (filterParams.priceFrom || filterParams.priceTo) { - result = result.filter((itinerary) => { + result = result.filter((itinerary: T) => { const filtered = filterByPriceRange( itinerary, filterParams.priceFrom, @@ -140,12 +165,10 @@ const filter = (itineraries, filterParams) => { * @param {*} filterParams the filters to apply. Specifically we use filterParams.page (default 1) and filterParams.limit (default RESULTS_SEARCH_LIMIT) * @returns a paginated copy of the itineraries */ -const paginate = (itineraries, filterParams) => { +const paginate = (source: T[], filterParams: FilterParams) => { const page = filterParams.page ?? 1; const limit = filterParams.limit ?? RESULTS_SEARCH_LIMIT; - const result = JSON.parse(JSON.stringify(itineraries)); - return result.slice((page - 1) * limit, page * limit); - // return result; + return source.slice((page - 1) * limit, page * limit); }; /** @@ -154,13 +177,18 @@ const paginate = (itineraries, filterParams) => { * @param {*} filterParams the filters to apply. Specifically we use filterParams.sort (default DEFAULT_SORT_FIELD) * @returns a sorted copy of the itineraries */ -const sort = (itineraries, filterParams) => { +const sort = ( + source: T[], + filterParams: FilterParams +) => { + const resultAfterClone = cloneDeep(source); const sortBy = filterParams.sort ?? DEFAULT_SORT_FIELD; - const result = JSON.parse(JSON.stringify(itineraries)); - if (sortBy === 'price') return result.sort((a, b) => a.price - b.price); + + if (sortBy === 'price') + return resultAfterClone.sort((a, b) => a.price - b.price); if (sortBy === 'distance') - return result.sort((a, b) => a.distance - b.distance); - return result.sort((a, b) => a.price - b.price); + return resultAfterClone.sort((a, b) => a.distance - b.distance); + return resultAfterClone.sort((a, b) => a.price - b.price); }; /** @@ -169,10 +197,13 @@ const sort = (itineraries, filterParams) => { * @param {*} filterParams filters * @returns a copy of the itineraries */ -const applyFilters = (itineraries, filterParams) => { - if (!itineraries || !filterParams) return itineraries; +const applyFilters = ( + source: T[], + filterParams: FilterParams +): T[] => { + if (!source || !filterParams) return source; - let filtered = filter(itineraries, filterParams); + let filtered = filter(source, filterParams); filtered = sort(filtered, filterParams); filtered = paginate(filtered, filterParams); return filtered; @@ -183,7 +214,7 @@ const applyFilters = (itineraries, filterParams) => { * @param {*} req * @returns a URLSearchPArams object representing the current url for that request */ -const getCurrentUrlFromRequest = (req) => { +const getCurrentUrlFromRequest = (req: Request) => { const urlSearchParamsBase = new URLSearchParams(); const { departureDate, returnDate, origins } = req.body?.departureDate ? req.body @@ -191,7 +222,7 @@ const getCurrentUrlFromRequest = (req) => { if (departureDate) urlSearchParamsBase.append('departureDate', departureDate); if (returnDate) urlSearchParamsBase.append('returnDate', returnDate); if (origins) { - origins.flyFrom.forEach((_, i) => { + origins.flyFrom.forEach((_: any, i: number) => { urlSearchParamsBase.append('origins[][flyFrom]', origins.flyFrom[i]); urlSearchParamsBase.append('origins[][adults]', origins.adults[i]); urlSearchParamsBase.append('origins[][children]', origins.children[i]); @@ -208,7 +239,11 @@ const getCurrentUrlFromRequest = (req) => { * @param {*} hasNextUrl true if we should add a next url * @returns an object representing the navigation links, with the following properties: previous, next, sort, sortByPrice, sortByDistance. Each property has an URL based on the base current url. */ -const buildNavigationUrlsFromRequest = (req, route, hasNextUrl) => { +const buildNavigationUrlsFromRequest = ( + req: TypedRequestQueryWithFilter, + route: string, + hasNextUrl: boolean +) => { const urlSearchParamsBase = getCurrentUrlFromRequest(req); const { page, sort, priceFrom, priceTo, maxConnections } = req.filter; @@ -244,6 +279,15 @@ const buildNavigationUrlsFromRequest = (req, route, hasNextUrl) => { return navigation; }; +const isItinerary = (value: unknown): value is Itinerary => { + return (value as Itinerary).onewayRoute !== undefined; +}; +const isDestinationWithItineraries = ( + value: unknown +): value is DestinationWithItineraries => { + return (value as DestinationWithItineraries).itineraries !== undefined; +}; + export = { filterByMaxConnections, filterByPriceRange, diff --git a/src/utils/resultsHelper.test.ts b/src/utils/resultsHelper.unit.test.ts similarity index 66% rename from src/utils/resultsHelper.test.ts rename to src/utils/resultsHelper.unit.test.ts index 6572417..2fcd9ca 100644 --- a/src/utils/resultsHelper.test.ts +++ b/src/utils/resultsHelper.unit.test.ts @@ -1,165 +1,207 @@ import helper from './resultsHelper'; import { RESULTS_SEARCH_LIMIT } from '../config'; +import { + DestinationWithItineraries, + FilterParams, + Itinerary, + QueryParams, + RegularFlightsParams, + Route, +} from '../common/types'; +import { Request } from 'express-serve-static-core'; +import { TypedRequestQueryWithFilter } from '../common/interfaces'; describe('Results Helper', () => { - const ZERO_CONNECTIONS = []; + const ZERO_CONNECTIONS: string[] = []; const ONE_CONNECTION = ['London']; const TWO_CONNECTIONS = ['London', 'Chicago']; - const ITINERARY_SEVERAL_ORIGINS = { + const DESTINATION_WITH_ITINERARIES: Partial = { cityTo: 'Dallas', - flights: [ + itineraries: [ { flyFrom: 'CDG', - route: { - oneway: { - connections: ZERO_CONNECTIONS, - }, - return: { - connections: ONE_CONNECTION, - }, + + onewayRoute: { + connections: ZERO_CONNECTIONS, + }, + returnRoute: { + connections: ONE_CONNECTION, }, + price: 978, // fare: { adults: 978 }, - }, + } as Itinerary, { flyFrom: 'LYS', - route: { - oneway: { - connections: TWO_CONNECTIONS, - }, - return: { - connections: TWO_CONNECTIONS, - }, + onewayRoute: { + connections: TWO_CONNECTIONS, }, + returnRoute: { + connections: TWO_CONNECTIONS, + }, + price: 1245, //fare: { adults: 1245 }, - }, + } as Itinerary, ], price: 2223, // 978 + 12 }; - const ITINERARY_SEVERAL_ORIGINS_2 = { + const DESTINATION_WITH_ITINERARIES_2: Partial = { cityTo: 'Bangkok', - flights: [ + itineraries: [ { flyFrom: 'CDG', - route: { - oneway: { - connections: ZERO_CONNECTIONS, - }, - return: { - connections: ONE_CONNECTION, - }, + onewayRoute: { + connections: ZERO_CONNECTIONS, }, + returnRoute: { + connections: ONE_CONNECTION, + }, + price: 1521, // fare: { adults: 978 }, - }, + } as Itinerary, { flyFrom: 'BER', - route: { - oneway: { - connections: TWO_CONNECTIONS, - }, - return: { - connections: TWO_CONNECTIONS, - }, + + onewayRoute: { + connections: TWO_CONNECTIONS, }, + returnRoute: { + connections: TWO_CONNECTIONS, + }, + price: 1314, //fare: { adults: 1245 }, - }, + } as Itinerary, ], price: 2835, // 1521 + 1314 }; - const ITINERARY_ONE_ORIGIN = { + const ITINERARY_ONE_ORIGIN: Partial = { cityTo: 'Dallas', flyFrom: 'CDG', - route: { - oneway: { - connections: ONE_CONNECTION, - }, - return: { - connections: ONE_CONNECTION, - }, - }, + onewayRoute: { + connections: ONE_CONNECTION, + } as Route, + returnRoute: { + connections: ONE_CONNECTION, + } as Route, + price: 1120, // fare: { adults: 1120 }, }; describe('filterByMaxConnections', () => { test('should return true when there are several origins and no flights have more than 2 connections', () => { - expect(helper.filterByMaxConnections(ITINERARY_SEVERAL_ORIGINS, 2)).toBe( - true - ); + expect( + helper.filterByMaxConnections( + DESTINATION_WITH_ITINERARIES as DestinationWithItineraries, + 2 + ) + ).toBe(true); }); test('should return true when there is only origin and no flights have more than 2 connections', () => { - expect(helper.filterByMaxConnections(ITINERARY_ONE_ORIGIN, 2)).toBe(true); + expect( + helper.filterByMaxConnections(ITINERARY_ONE_ORIGIN as Itinerary, 2) + ).toBe(true); }); test('should return false when there are several origins and at least one flight have more than 1 connection', () => { - expect(helper.filterByMaxConnections(ITINERARY_SEVERAL_ORIGINS, 1)).toBe( - false - ); + expect( + helper.filterByMaxConnections( + DESTINATION_WITH_ITINERARIES as DestinationWithItineraries, + 1 + ) + ).toBe(false); }); test('should return false when there is only one origin and at least one flight have more than 0 connection', () => { - expect(helper.filterByMaxConnections(ITINERARY_ONE_ORIGIN, 0)).toBe( - false - ); + expect( + helper.filterByMaxConnections(ITINERARY_ONE_ORIGIN as Itinerary, 0) + ).toBe(false); }); }); describe('filterByPriceRange', () => { test('should return true when there are several origins and all the flights prices are inside the price range', () => { expect( - helper.filterByPriceRange(ITINERARY_SEVERAL_ORIGINS, 900, 1300) + helper.filterByPriceRange( + DESTINATION_WITH_ITINERARIES as DestinationWithItineraries, + 900, + 1300 + ) ).toBe(true); }); test('should return true when there is only one origin and the flight price is inside the price range', () => { - expect(helper.filterByPriceRange(ITINERARY_ONE_ORIGIN, 900, 1300)).toBe( - true - ); + expect( + helper.filterByPriceRange(ITINERARY_ONE_ORIGIN as Itinerary, 900, 1300) + ).toBe(true); }); test('should return true when there are several origins, no min price has been specified and all the flights prices are below the max price', () => { expect( - helper.filterByPriceRange(ITINERARY_SEVERAL_ORIGINS, undefined, 1300) + helper.filterByPriceRange( + DESTINATION_WITH_ITINERARIES as DestinationWithItineraries, + undefined, + 1300 + ) ).toBe(true); }); test('should return true when there are several origins, no max price has been specified and all the flights prices are above the min price', () => { expect( - helper.filterByPriceRange(ITINERARY_SEVERAL_ORIGINS, 900, undefined) + helper.filterByPriceRange( + DESTINATION_WITH_ITINERARIES as DestinationWithItineraries, + 900, + undefined + ) ).toBe(true); }); test('should return true when there is only one origin, no min price has been specified and the flight price is below the max price', () => { expect( - helper.filterByPriceRange(ITINERARY_ONE_ORIGIN, undefined, 1300) + helper.filterByPriceRange( + ITINERARY_ONE_ORIGIN as Itinerary, + undefined, + 1300 + ) ).toBe(true); }); test('should return true when there is only one origin, no max price has been specified and the flight price is above the min price', () => { expect( - helper.filterByPriceRange(ITINERARY_ONE_ORIGIN, 900, undefined) + helper.filterByPriceRange( + ITINERARY_ONE_ORIGIN as Itinerary, + 900, + undefined + ) ).toBe(true); }); test('should return false when there are several origins and at least one flight price is not inside the price range', () => { expect( - helper.filterByPriceRange(ITINERARY_SEVERAL_ORIGINS, 1200, 1300) + helper.filterByPriceRange( + DESTINATION_WITH_ITINERARIES as DestinationWithItineraries, + 1200, + 1300 + ) ).toBe(false); }); test('should return true when there is only one origin and the flight price is not inside the price range', () => { - expect(helper.filterByPriceRange(ITINERARY_ONE_ORIGIN, 1200, 1300)).toBe( - false - ); + expect( + helper.filterByPriceRange(ITINERARY_ONE_ORIGIN as Itinerary, 1200, 1300) + ).toBe(false); }); }); describe('getFilters', () => { test('should returns an object with all the current filters applied', () => { const itineraries = [ - ITINERARY_SEVERAL_ORIGINS, - ITINERARY_SEVERAL_ORIGINS_2, + DESTINATION_WITH_ITINERARIES, + DESTINATION_WITH_ITINERARIES_2, ]; const filterParams = { priceFrom: 13, priceTo: 424, maxConnections: 1, }; - const filters = helper.getFilters(itineraries, filterParams); + const filters = helper.getFilters( + itineraries as DestinationWithItineraries[], + filterParams as FilterParams + ); expect(filters.minPossiblePrice).toEqual(978); expect(filters.maxPossiblePrice).toEqual(1521); @@ -191,27 +233,42 @@ describe('Results Helper', () => { ]; test('should return only some itineraries for regular cases', () => { - const result = helper.paginate(itineraries, { page: 2, limit: 3 }); + const result = helper.paginate( + itineraries as unknown as Itinerary[], + { page: 2, limit: 3 } as FilterParams + ); expect(result).toStrictEqual(['item4', 'item5', 'item6']); }); test('should return the first itineraries for the first page', () => { - const result = helper.paginate(itineraries, { page: 1, limit: 3 }); + const result = helper.paginate( + itineraries as unknown as Itinerary[], + { page: 1, limit: 3 } as FilterParams + ); expect(result).toStrictEqual(['item1', 'item2', 'item3']); }); test('should return the last itineraries for the last page', () => { - const result = helper.paginate(itineraries, { page: 4, limit: 3 }); + const result = helper.paginate( + itineraries as unknown as Itinerary[], + { page: 4, limit: 3 } as FilterParams + ); expect(result).toStrictEqual(['item10']); }); test('should return no itineraries if page is too big', () => { - const result = helper.paginate(itineraries, { page: 5, limit: 3 }); + const result = helper.paginate( + itineraries as unknown as Itinerary[], + { page: 5, limit: 3 } as FilterParams + ); expect(result).toStrictEqual([]); }); test('should return all the itineraries if limit is bigger than the number of itineraries', () => { - const result = helper.paginate(itineraries, { page: 1, limit: 12 }); + const result = helper.paginate( + itineraries as unknown as Itinerary[], + { page: 1, limit: 12 } as FilterParams + ); expect(result).toStrictEqual(itineraries); }); @@ -227,7 +284,10 @@ describe('Results Helper', () => { itineraries, itineraries, ].flat(); - const result = helper.paginate(manyResults, {}); + const result = helper.paginate( + manyResults as unknown as Itinerary[], + {} as FilterParams + ); expect(result).toHaveLength(RESULTS_SEARCH_LIMIT); }); @@ -249,7 +309,10 @@ describe('Results Helper', () => { ]; test('should sort by ascending price when no sort parameters', () => { - const sorted = helper.sort(itineraries, {}); + const sorted = helper.sort( + itineraries as Itinerary[], + {} as FilterParams + ); expect(sorted[0].cityTo).toEqual('Paris'); expect(sorted[1].cityTo).toEqual('London'); expect(sorted[2].cityTo).toEqual('Barcelona'); @@ -257,21 +320,30 @@ describe('Results Helper', () => { }); test('should sort by ascending total price when sort=price parameter is provided', () => { - const sorted = helper.sort(itineraries, { sort: 'price' }); + const sorted = helper.sort( + itineraries as Itinerary[], + { sort: 'price' } as FilterParams + ); expect(sorted[0].cityTo).toEqual('Paris'); expect(sorted[1].cityTo).toEqual('London'); expect(sorted[2].cityTo).toEqual('Barcelona'); expect(sorted[3].cityTo).toEqual('Bangkok'); }); test('should sort by ascending total distance when sort=distance parameter is provided', () => { - const sorted = helper.sort(itineraries, { sort: 'distance' }); + const sorted = helper.sort( + itineraries as Itinerary[], + { sort: 'distance' } as FilterParams + ); expect(sorted[0].cityTo).toEqual('Barcelona'); expect(sorted[1].cityTo).toEqual('Paris'); expect(sorted[2].cityTo).toEqual('London'); expect(sorted[3].cityTo).toEqual('Bangkok'); }); test('should sort by ascending price when the sort parameter is a non-sortable field', () => { - const sorted = helper.sort(itineraries, { sort: 'departureDate' }); + const sorted = helper.sort( + itineraries as Itinerary[], + { sort: 'departureDate' } as FilterParams + ); expect(sorted[0].cityTo).toEqual('Paris'); expect(sorted[1].cityTo).toEqual('London'); expect(sorted[2].cityTo).toEqual('Barcelona'); @@ -279,7 +351,7 @@ describe('Results Helper', () => { }); test('should return an empty array if the itineraries argument is empty', () => { - const sorted = helper.sort([], {}); + const sorted = helper.sort([], {} as FilterParams); expect(sorted).toBeInstanceOf(Array); expect(sorted).toHaveLength(0); }); @@ -306,7 +378,7 @@ describe('Results Helper', () => { }, }; - const url = helper.getCurrentUrlFromRequest(req); + const url = helper.getCurrentUrlFromRequest(req as unknown as Request); expect(url).toBeInstanceOf(URLSearchParams); expect(url.get('departureDate')).toBe('2022-09-22'); expect(url.getAll('origins[][flyFrom]')).toEqual( @@ -326,7 +398,7 @@ describe('Results Helper', () => { }, }; - const url = helper.getCurrentUrlFromRequest(req); + const url = helper.getCurrentUrlFromRequest(req as unknown as Request); expect(url).toBeInstanceOf(URLSearchParams); expect(url.get('departureDate')).toBe('2022-09-22'); expect(url.getAll('origins[][flyFrom]')).toEqual( @@ -336,7 +408,8 @@ describe('Results Helper', () => { }); describe('buildNavigationUrlsFromRequest', () => { - let req, route; + let req: Partial>; + let route: string; beforeEach(() => { req = { body: { @@ -354,13 +427,13 @@ describe('Results Helper', () => { priceFrom: 32, priceTo: 522, maxConnections: 0, - }, + } as FilterParams, }; route = '/common'; }); test('should return an object with previous, next, sort, sortByPrice and sortByDistance fields', () => { const navigation = helper.buildNavigationUrlsFromRequest( - req, + req as unknown as TypedRequestQueryWithFilter, route, true ); @@ -374,7 +447,7 @@ describe('Results Helper', () => { test('should return a previous url with a page field in a normal scenario', () => { const navigation = helper.buildNavigationUrlsFromRequest( - req, + req as unknown as TypedRequestQueryWithFilter, route, true ); @@ -384,7 +457,7 @@ describe('Results Helper', () => { test('should return a null previous url when there is no page parameter', () => { delete req.filter.page; const navigation = helper.buildNavigationUrlsFromRequest( - req, + req as unknown as TypedRequestQueryWithFilter, route, true ); @@ -393,7 +466,7 @@ describe('Results Helper', () => { test('should return a null previous url when page parameter is 1', () => { req.filter.page = 1; const navigation = helper.buildNavigationUrlsFromRequest( - req, + req as unknown as TypedRequestQueryWithFilter, route, true ); @@ -402,7 +475,7 @@ describe('Results Helper', () => { test('should return a null next url when no next page is requested', () => { const navigation = helper.buildNavigationUrlsFromRequest( - req, + req as unknown as TypedRequestQueryWithFilter, route, false ); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index a9da8a8..e27fa59 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -3,10 +3,10 @@ import utf8 from 'utf8'; /** * Helper to reencode data from airport json data source - * @param {*} str - * @returns + * @param {*} str + * @returns */ -const reencodeString = (str) => { +const reencodeString = (str: string) => { const result = utf8.decode( buffer.transcode(Buffer.from(str), 'utf8', 'latin1').toString('latin1') ); @@ -15,10 +15,10 @@ const reencodeString = (str) => { /** * Helper to normalize data from airport data json source - * @param {*} str - * @returns + * @param {*} str + * @returns */ -const normalizeString = (str) => { +const normalizeString = (str: string) => { const result = reencodeString(str) .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); @@ -30,8 +30,8 @@ const normalizeString = (str) => { * @param {*} obj * @param {...any} allowedFields */ -const filterObj = (obj, allowedFields) => { - const newObj = {}; +const filterObj = (obj: any, allowedFields: string[]) => { + const newObj: any = {}; Object.keys(obj).forEach((el) => { if (allowedFields.includes(el)) { newObj[el] = obj[el]; diff --git a/src/utils/utils.test.ts b/src/utils/utils.unit.test.ts similarity index 100% rename from src/utils/utils.test.ts rename to src/utils/utils.unit.test.ts diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 5d8bfc3..4238d9f 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -1,4 +1,5 @@ -import { isAlpha, isNumeric } from 'validator'; +import validatorJs from 'validator'; +import { ParamModel, QueryParams } from '../common/types'; // const validateParams = (req, res, next) => {}; @@ -7,9 +8,9 @@ import { isAlpha, isNumeric } from 'validator'; * @param {*} str * @returns true if str is a comma-separated list of alphabetic strings (i.e. 'MAD,BRU,POE'), false otherwise (i.e. 'MAD-BRU-POE', or 'MAD-BRU2-POR') */ -const isCommaSeparatedAlpha = (str) => { +const isCommaSeparatedAlpha = (str: string): boolean => { const splitted = str.split(','); - return splitted.every((split) => isAlpha(split)); + return splitted.every((split) => validatorJs.isAlpha(split)); }; /** @@ -17,9 +18,9 @@ const isCommaSeparatedAlpha = (str) => { * @param {*} str * @returns true if str is a comma-separated list of numbers (i.e. '1,2,2') */ -const isCommaSeparatedNumeric = (str) => { +const isCommaSeparatedNumeric = (str: string): boolean => { const splitted = str.split(','); - return splitted.every(isNumeric); + return splitted.every((split) => validatorJs.isNumeric(split)); }; /** @@ -28,7 +29,7 @@ const isCommaSeparatedNumeric = (str) => { * @param {*} params * @returns list of missing params name */ -const findMissingParams = (model, params) => { +const findMissingParams = (model: ParamModel[], params: QueryParams) => { const missingParams = model .filter((param) => param.required && !params[param.name]) .map((param) => param.name); @@ -41,7 +42,7 @@ const findMissingParams = (model, params) => { * @param {*} params * @returns list of params name for which we have a wrong type */ -const findWrongTypeParams = (model, params) => { +const findWrongTypeParams = (model: ParamModel[], params: QueryParams) => { return model .filter( (param) => params[param.name] && !param.typeCheck(params[param.name]) diff --git a/src/utils/validator.test.ts b/src/utils/validator.unit.test.ts similarity index 61% rename from src/utils/validator.test.ts rename to src/utils/validator.unit.test.ts index 9b526e5..82c9408 100644 --- a/src/utils/validator.test.ts +++ b/src/utils/validator.unit.test.ts @@ -1,52 +1,55 @@ -import validator from './validator'; -import { isAlpha, isDate, isNumeric } from 'validator'; +import pulpitoValidator from './validator'; +import validator from 'validator'; +import { ParamModel } from '../common/types'; describe('validator utils', () => { describe('isCommaSeparatedAlpha', () => { test('should return true when argument is a unique string of letters', function () { - expect(validator.isCommaSeparatedAlpha('MAD')).toBe(true); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD')).toBe(true); }); test('should return true when argument is a comma separated string of letters', function () { - expect(validator.isCommaSeparatedAlpha('MAD,OPO,BRU')).toBe(true); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD,OPO,BRU')).toBe(true); }); test('should return false when argument contains numbers', function () { - expect(validator.isCommaSeparatedAlpha('MAD2')).toBe(false); - expect(validator.isCommaSeparatedAlpha('MAD2,OPO,BRU')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD2')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD2,OPO,BRU')).toBe( + false + ); }); test('should return false when argument has not the correct comma-separator', function () { - expect(validator.isCommaSeparatedAlpha('MAD;OPO;BRU')).toBe(false); - expect(validator.isCommaSeparatedAlpha('MAD-OPO-BRU')).toBe(false); - expect(validator.isCommaSeparatedAlpha('MAD OPO BRU')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD;OPO;BRU')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD-OPO-BRU')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD OPO BRU')).toBe(false); }); test('should return false when argument is empty', function () { - expect(validator.isCommaSeparatedAlpha('')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedAlpha('')).toBe(false); }); test('should return false when argument ends with a comma', function () { - expect(validator.isCommaSeparatedAlpha('MAD,')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedAlpha('MAD,')).toBe(false); }); }); describe('isCommaSeparatedNumeric', () => { test('should return true when argument is a number', function () { - expect(validator.isCommaSeparatedNumeric('1')).toBe(true); + expect(pulpitoValidator.isCommaSeparatedNumeric('1')).toBe(true); }); test('should return true when argument is a comma separated string of numbers', function () { - expect(validator.isCommaSeparatedNumeric('1,1,1')).toBe(true); + expect(pulpitoValidator.isCommaSeparatedNumeric('1,1,1')).toBe(true); }); test('should return false when argument contains letters', function () { - expect(validator.isCommaSeparatedNumeric('MAD')).toBe(false); - expect(validator.isCommaSeparatedNumeric('1,2,a')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedNumeric('MAD')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedNumeric('1,2,a')).toBe(false); }); test('should return false when argument has not the correct comma-separator', function () { - expect(validator.isCommaSeparatedNumeric('1;2;3')).toBe(false); - expect(validator.isCommaSeparatedNumeric('1-2-3')).toBe(false); - expect(validator.isCommaSeparatedNumeric('1 2 3')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedNumeric('1;2;3')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedNumeric('1-2-3')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedNumeric('1 2 3')).toBe(false); }); test('should return false when argument is empty', function () { - expect(validator.isCommaSeparatedNumeric('')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedNumeric('')).toBe(false); }); test('should return false when argument ends with a comma', function () { - expect(validator.isCommaSeparatedNumeric('2,4,')).toBe(false); + expect(pulpitoValidator.isCommaSeparatedNumeric('2,4,')).toBe(false); }); }); @@ -68,7 +71,7 @@ describe('validator utils', () => { name: 'nonRequiredParam1', required: false, }, - ]; + ] as ParamModel[]; test('should return an empty array if no parameters from given model are missing', () => { const params = { @@ -77,7 +80,7 @@ describe('validator utils', () => { requiredParam3: 'foobar', nonRequiredParam1: 'foobar', }; - expect(validator.findMissingParams(model, params)).toEqual([]); + expect(pulpitoValidator.findMissingParams(model, params)).toEqual([]); }); test('should return an array of the missing parameters names when they are missing', function () { const params = { @@ -85,10 +88,10 @@ describe('validator utils', () => { nonRequiredParam1: 'foobar', nonRequiredParam2: 'boofar', }; - expect(validator.findMissingParams(model, params)).toContain( + expect(pulpitoValidator.findMissingParams(model, params)).toContain( 'requiredParam1' ); - expect(validator.findMissingParams(model, params)).toContain( + expect(pulpitoValidator.findMissingParams(model, params)).toContain( 'requiredParam2' ); }); @@ -98,17 +101,17 @@ describe('validator utils', () => { const model = [ { name: 'alphaParam', - typeCheck: isAlpha, + typeCheck: validator.isAlpha, }, { name: 'numericParam', - typeCheck: isNumeric, + typeCheck: validator.isNumeric, }, { name: 'dateParam', - typeCheck: (str) => isDate(str, { format: 'DD/MM/YYYY' }), + typeCheck: (str) => validator.isDate(str, { format: 'DD/MM/YYYY' }), }, - ]; + ] as ParamModel[]; test('should return an empty array if parameters have the correct type', () => { const params = { @@ -116,7 +119,7 @@ describe('validator utils', () => { numericParam: '42', dateParam: '22/06/1984', }; - expect(validator.findWrongTypeParams(model, params)).toEqual([]); + expect(pulpitoValidator.findWrongTypeParams(model, params)).toEqual([]); }); test(`should return ['alphaParam'] when alphaParam contains something else than letters`, () => { @@ -125,7 +128,7 @@ describe('validator utils', () => { numericParam: '42', dateParam: '22/06/1984', }; - expect(validator.findWrongTypeParams(model, params)).toEqual([ + expect(pulpitoValidator.findWrongTypeParams(model, params)).toEqual([ 'alphaParam', ]); }); @@ -136,7 +139,7 @@ describe('validator utils', () => { numericParam: '4a2', dateParam: '22/06/1984', }; - expect(validator.findWrongTypeParams(model, params)).toEqual([ + expect(pulpitoValidator.findWrongTypeParams(model, params)).toEqual([ 'numericParam', ]); }); @@ -147,7 +150,7 @@ describe('validator utils', () => { numericParam: '42', dateParam: '1984/06/22', }; - expect(validator.findWrongTypeParams(model, params)).toEqual([ + expect(pulpitoValidator.findWrongTypeParams(model, params)).toEqual([ 'dateParam', ]); }); @@ -158,10 +161,10 @@ describe('validator utils', () => { numericParam: '4a2', dateParam: '22/06/1984', }; - expect(validator.findWrongTypeParams(model, params)).toContain( + expect(pulpitoValidator.findWrongTypeParams(model, params)).toContain( 'alphaParam' ); - expect(validator.findWrongTypeParams(model, params)).toContain( + expect(pulpitoValidator.findWrongTypeParams(model, params)).toContain( 'numericParam' ); }); diff --git a/src/utils/xss.ts b/src/utils/xss.ts new file mode 100644 index 0000000..814877b --- /dev/null +++ b/src/utils/xss.ts @@ -0,0 +1,22 @@ +import { inHTMLData } from 'xss-filters'; +import { RequestHandler, Request } from 'express'; + +const clean = (req: Request): Request => { + if (req.body) + req.body = JSON.parse(inHTMLData(JSON.stringify(req.body)).trim()); + if (req.query) + req.query = JSON.parse(inHTMLData(JSON.stringify(req.query)).trim()); + if (req.params) + req.params = JSON.parse(inHTMLData(JSON.stringify(req.params)).trim()); + + return req; +}; + +const xss = (): RequestHandler => { + return (req, _res, next) => { + req = clean(req); + next(); + }; +}; + +export default xss; diff --git a/src/views/common.pug b/src/views/common.pug index 34f7f8c..e9ca082 100644 --- a/src/views/common.pug +++ b/src/views/common.pug @@ -14,7 +14,7 @@ block content - // Flight Search Areas + // flight Search Areas section#explore_area.section_padding .container // Section Heading @@ -36,19 +36,19 @@ block content h5 Escales .tour_search_type.btn_radio_max_connections .form-check - input.form-check-input#flexCheckDefaultf1(name='maxConnections' type='radio' value=0 checked=filters.maxConnections==='0') + input.form-check-input#flexCheckDefaultf1(name='maxConnections' type='radio' value=0 checked=filters.maxConnections===0) label.form-check-label(for='flexCheckDefaultf1') span.area_flex_one span Direct (Sans escale) span .form-check - input.form-check-input#flexCheckDefaultf2(name='maxConnections' type='radio' value=1 checked=filters.maxConnections==='1') + input.form-check-input#flexCheckDefaultf2(name='maxConnections' type='radio' value=1 checked=filters.maxConnections===1) label.form-check-label(for='flexCheckDefaultf2') span.area_flex_one span Jusque 1 escale span .form-check - input.form-check-input#flexCheckDefaultf2(name='maxConnections' type='radio' value=2 checked=!filters.maxConnections || filters.maxConnections==='2') + input.form-check-input#flexCheckDefaultf2(name='maxConnections' type='radio' value=2 checked=!filters.maxConnections || filters.maxConnections===2) label.form-check-label(for='flexCheckDefaultf3') span.area_flex_one span Tous les vols @@ -73,7 +73,7 @@ block content span Distance totale .flight_search_result_wrapper - each itinerary,i in data + each destination,i in data - var index = i + 1 //- .flight_search_item_wrappper(data-bs-toggle='collapse' data-bs-target=`#collapseExample${index}` aria-expanded='false' aria-controls=`#collapseExample${index}`) .flight_search_item_wrappper @@ -83,15 +83,15 @@ block content .flight_search_left .flight_search_destination p Destination: - h3=`${itinerary.cityTo} (${itinerary.cityCodeTo})` - h6=itinerary.countryTo + h3=`${destination.cityTo} (${destination.cityCodeTo})` + h6=destination.countryTo .flight_search_right - var links = []; - each flight in itinerary.flights - - links.push(flight.deep_link) + each itinerary in destination.itineraries + - links.push(itinerary.deep_link) .numbers - h2=`${itinerary.price} €` - h4=`(${itinerary.distance} km)` + h2=`${destination.price} €` + h4=`(${destination.distance} km)` a.btn.btn_theme.btn_sm.btn_book_all(data-links=links) Réserver tous les vols ↗️ h6 | Détails @@ -103,62 +103,62 @@ block content .flight_inner_show_component Aller .flight_inner_show_component Retour .TabPanelInner Prix - each flight in itinerary.flights + each itinerary in destination.itineraries .flight_show_down_wrapper .airline-details - h3=`${flight.cityFrom}` - - var onewayConnections = (flight.route.oneway.connections.length > 0) ? flight.route.oneway.connections : null + h3=`${itinerary.cityFrom}` + - var onewayConnections = (itinerary.onewayRoute.connections.length > 0) ? itinerary.onewayRoute.connections : null .flight_inner_show_component_container .flight_inner_show_component .flight_det_wrapper .flight_det .code_time - span.code=flight.flyFrom - span.time=`${flight.route.oneway.local_departure.split(',')[1].trim()}` - p.airport=`${flight.cityFrom}` + span.code=itinerary.flyFrom + span.time=`${itinerary.onewayRoute.local_departure.split(',')[1].trim()}` + p.airport=`${itinerary.cityFrom}` - p.date=`${flight.route.oneway.local_departure.split(',')[0].trim()}` + p.date=`${itinerary.onewayRoute.local_departure.split(',')[0].trim()}` .flight_duration .arrow_right - if flight.route.oneway.connections.length > 0 - span=flight.route.oneway.connections - p=flight.route.oneway.duration + if itinerary.onewayRoute.connections.length > 0 + span=itinerary.onewayRoute.connections + p=itinerary.onewayRoute.duration .flight_det_wrapper .flight_det .code_time - span.code=flight.flyTo - span.time=`${flight.route.oneway.local_arrival.split(',')[1].trim()}` - p.airport=`${flight.cityTo}` + span.code=itinerary.flyTo + span.time=`${itinerary.onewayRoute.local_arrival.split(',')[1].trim()}` + p.airport=`${itinerary.cityTo}` - p.date=`${flight.route.oneway.local_arrival.split(',')[0].trim()}` + p.date=`${itinerary.onewayRoute.local_arrival.split(',')[0].trim()}` .flight_inner_show_component .flight_det_wrapper .flight_det .code_time - span.code=flight.flyTo - span.time=`${flight.route.return.local_departure.split(',')[1].trim()}` - p.airport=`${flight.cityTo}` + span.code=itinerary.flyTo + span.time=`${itinerary.returnRoute.local_departure.split(',')[1].trim()}` + p.airport=`${itinerary.cityTo}` - p.date=`${flight.route.return.local_departure.split(',')[0].trim()}` + p.date=`${itinerary.returnRoute.local_departure.split(',')[0].trim()}` .flight_duration .arrow_left - if flight.route.return.connections.length > 0 - span=flight.route.return.connections - p=flight.route.return.duration + if itinerary.returnRoute.connections.length > 0 + span=itinerary.returnRoute.connections + p=itinerary.returnRoute.duration .flight_det_wrapper .flight_det .code_time - span.code=flight.flyFrom - span.time=`${flight.route.return.local_arrival.split(',')[1].trim()}` - p.airport=`${flight.cityFrom}` + span.code=itinerary.flyFrom + span.time=`${itinerary.returnRoute.local_arrival.split(',')[1].trim()}` + p.airport=`${itinerary.cityFrom}` - p.date=`${flight.route.return.local_arrival.split(',')[0].trim()}` + p.date=`${itinerary.returnRoute.local_arrival.split(',')[0].trim()}` .TabPanelInner .flight_info_taable - h3=`${flight.fare.adults} € / pers.` - p=`(${flight.distance} km / pers.)` - a.btn.btn_theme.btn_sm.btn_book(target='_blank',href=`${flight.deep_link}`)=`Réserver ce vol (${flight.price} €) ↗️` + h3=`${itinerary.fare.adults} € / pers.` + p=`(${itinerary.distance} km / pers.)` + a.btn.btn_theme.btn_sm.btn_book(target='_blank',href=`${itinerary.deep_link}`)=`Réserver ce vol (${itinerary.price} €) ↗️` if (navigation) .filter_results .pagination_results diff --git a/src/views/viewController.ts b/src/views/viewController.ts index 134e33f..5edc6c9 100644 --- a/src/views/viewController.ts +++ b/src/views/viewController.ts @@ -4,13 +4,16 @@ import helper from '../utils/apiHelper'; import resultsHelper from '../utils/resultsHelper'; import { RESULTS_SEARCH_LIMIT } from '../config'; import { fillAirportDescriptions } from '../airports/airportService'; +import { Request, Response } from 'express-serve-static-core'; +import { TypedRequestQueryWithFilter } from '../common/interfaces'; +import { FilterParams, RegularFlightsParams } from '../common/types'; /** * Home route for interface * @param {*} req * @param {*} res */ -const getHome = (req, res) => { +const getHome = (req: Request, res: Response) => { res.status(200).render('home'); }; @@ -19,7 +22,7 @@ const getHome = (req, res) => { * @param {*} req * @param {*} res */ -const getSearchPage = (req, res) => { +const getSearchPage = (req: Request, res: Response) => { res.status(200).render('search', { status: 'success', totalResults: 0, @@ -31,71 +34,77 @@ const getSearchPage = (req, res) => { /** * Search page route for interface, with results from the search */ -const searchFlights = catchAsyncKiwi(async (req, res) => { - const requestParams = req.body && req.body.origins ? req.body : req.query; +const searchFlights = catchAsyncKiwi( + async ( + req: TypedRequestQueryWithFilter, + res: Response + ) => { + const requestParams = req.body && req.body.origins ? req.body : req.query; - if (!requestParams || !requestParams.origins) { - return res.status(200).render('search', { - status: 'success', - totalResults: 0, - shownResults: 0, - data: [], - }); - } - console.info( - 'UX - Getting common destinations with these params', - requestParams - ); + if (!requestParams || !requestParams.origins) { + return res.status(200).render('search', { + status: 'success', + totalResults: 0, + shownResults: 0, + data: [], + }); + } + console.info( + 'UX - Getting common destinations with these params', + requestParams + ); - const allOriginParams = - helper.prepareSeveralOriginAPIParamsFromView(requestParams); + // FIXME: once we migrated viewController to TS, we need to update prepareSeveralOriginAPIParamsFromView + const allOriginParams = + helper.prepareSeveralOriginAPIParamsFromView(requestParams); - const originCodes = requestParams.origins.flyFrom; + const originCodes = requestParams.origins.flyFrom; - try { - let commonItineraries = await destinationsService.buildCommonItineraries( - allOriginParams, - originCodes - ); - const totalResults = commonItineraries.length; + try { + let commonItineraries = await destinationsService.buildCommonItineraries( + allOriginParams, + originCodes + ); + const totalResults = commonItineraries.length; - const filters = resultsHelper.getFilters(commonItineraries, req.filter); + const filters = resultsHelper.getFilters(commonItineraries, req.filter); - commonItineraries = resultsHelper.applyFilters( - commonItineraries, - req.filter - ); + commonItineraries = resultsHelper.applyFilters( + commonItineraries, + req.filter + ); - requestParams.origins.flyFromDesc = fillAirportDescriptions( - requestParams.origins.flyFrom - ); + requestParams.origins.flyFromDesc = fillAirportDescriptions( + requestParams.origins.flyFrom + ); - const navigation = resultsHelper.buildNavigationUrlsFromRequest( - req, - `/common`, - commonItineraries.length === RESULTS_SEARCH_LIMIT - ); + const navigation = resultsHelper.buildNavigationUrlsFromRequest( + req, + `/common`, + commonItineraries.length === RESULTS_SEARCH_LIMIT + ); - // const commonItineraries = []; - res.status(200).render('common', { - status: 'success', - totalResults, - shownResults: commonItineraries.length, - data: commonItineraries, - request: requestParams, - filters, - navigation, - }); - } catch (err) { - console.error(err); - res.status(err.response?.status ?? 500).render('common', { - status: 'error', - totalResults: 0, - shownResults: 0, - error: err.response?.data.error ?? err.message, - request: requestParams, - }); + // const commonItineraries = []; + res.status(200).render('common', { + status: 'success', + totalResults, + shownResults: commonItineraries.length, + data: commonItineraries, + request: requestParams, + filters, + navigation, + }); + } catch (err) { + console.error(err); + res.status(err.response?.status ?? 500).render('common', { + status: 'error', + totalResults: 0, + shownResults: 0, + error: err.response?.data.error ?? err.message, + request: requestParams, + }); + } } -}); +); export = { getHome, getSearchPage, searchFlights }; diff --git a/src/views/viewRoutes.ts b/src/views/viewRoutes.ts index 898e186..1860f4f 100644 --- a/src/views/viewRoutes.ts +++ b/src/views/viewRoutes.ts @@ -1,5 +1,5 @@ import express from 'express'; -import validatorService from '../common/validatorService'; +import { filterParams } from '../middleware/validator/validatorService'; import viewController from './viewController'; export const router = express.Router(); @@ -8,16 +8,8 @@ router.get('/', viewController.getHome); router.get('/search', viewController.getSearchPage); -router.get( - '/common', - validatorService.filterParams, - viewController.searchFlights -); +router.get('/common', filterParams, viewController.searchFlights); // TODO: param requests are not 'validated' here although they are validated on front-end // we use filterParams to add an empty filter object on req parameter. -router.post( - '/common', - validatorService.filterParams, - viewController.searchFlights -); +router.post('/common', filterParams, viewController.searchFlights); diff --git a/tsconfig.json b/tsconfig.json index b6d5952..b1087a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,11 +7,11 @@ "sourceMap": true, "module": "CommonJS", "resolveJsonModule": true /* Enable importing .json files. */, - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, /* Type Checking */ // "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */ // "skipLibCheck": true /* Skip type checking all .d.ts files. */ },