From 715a11d52ac8c982de30642b072032f924f63cf6 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 14 Feb 2023 11:58:44 +0100 Subject: [PATCH 01/26] doc: updated Readme. --- README.en.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 From d4d686d005c993daec932422e8ed3990eafb8c11 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Wed, 15 Feb 2023 12:45:52 +0100 Subject: [PATCH 02/26] chore: bumped to 2.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c4f8439..f35f943 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": { From b073eb356dcf09ff2bd37f1c1c0b3a28975daf44 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Wed, 15 Feb 2023 12:50:10 +0100 Subject: [PATCH 03/26] refactor: separated between unit test files and integration test files --- .../{airportService.test.ts => airportService.unit.test.ts} | 0 ...atorService.test.ts => validatorService.integration.test.ts} | 0 ...{flightService.test.ts => flightService.integration.test.ts} | 0 ...oller.test.ts => destinationsController.integration.test.ts} | 0 ...sService.test.ts => destinationsService.integration.test.ts} | 0 src/tests/{functional.test.ts => endtoend.test.ts} | 0 ...uthController.test.ts => authController.integration.test.ts} | 2 +- ...serController.test.ts => userController.integration.test.ts} | 0 src/utils/{apiHelper.test.ts => apiHelper.unit.test.ts} | 0 src/utils/{resultsHelper.test.ts => resultsHelper.unit.test.ts} | 0 src/utils/{utils.test.ts => utils.unit.test.ts} | 0 src/utils/{validator.test.ts => validator.unit.test.ts} | 0 12 files changed, 1 insertion(+), 1 deletion(-) rename src/airports/{airportService.test.ts => airportService.unit.test.ts} (100%) rename src/common/{validatorService.test.ts => validatorService.integration.test.ts} (100%) rename src/data/{flightService.test.ts => flightService.integration.test.ts} (100%) rename src/destinations/{destinationsController.test.ts => destinationsController.integration.test.ts} (100%) rename src/destinations/{destinationsService.test.ts => destinationsService.integration.test.ts} (100%) rename src/tests/{functional.test.ts => endtoend.test.ts} (100%) rename src/user/{authController.test.ts => authController.integration.test.ts} (99%) rename src/user/{userController.test.ts => userController.integration.test.ts} (100%) rename src/utils/{apiHelper.test.ts => apiHelper.unit.test.ts} (100%) rename src/utils/{resultsHelper.test.ts => resultsHelper.unit.test.ts} (100%) rename src/utils/{utils.test.ts => utils.unit.test.ts} (100%) rename src/utils/{validator.test.ts => validator.unit.test.ts} (100%) diff --git a/src/airports/airportService.test.ts b/src/airports/airportService.unit.test.ts similarity index 100% rename from src/airports/airportService.test.ts rename to src/airports/airportService.unit.test.ts diff --git a/src/common/validatorService.test.ts b/src/common/validatorService.integration.test.ts similarity index 100% rename from src/common/validatorService.test.ts rename to src/common/validatorService.integration.test.ts diff --git a/src/data/flightService.test.ts b/src/data/flightService.integration.test.ts similarity index 100% rename from src/data/flightService.test.ts rename to src/data/flightService.integration.test.ts diff --git a/src/destinations/destinationsController.test.ts b/src/destinations/destinationsController.integration.test.ts similarity index 100% rename from src/destinations/destinationsController.test.ts rename to src/destinations/destinationsController.integration.test.ts 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/tests/functional.test.ts b/src/tests/endtoend.test.ts similarity index 100% rename from src/tests/functional.test.ts rename to src/tests/endtoend.test.ts diff --git a/src/user/authController.test.ts b/src/user/authController.integration.test.ts similarity index 99% rename from src/user/authController.test.ts rename to src/user/authController.integration.test.ts index 517cf19..f02d792 100644 --- a/src/user/authController.test.ts +++ b/src/user/authController.integration.test.ts @@ -3,7 +3,7 @@ import authController from './authController'; // import AppError from '../utils/appError'; import mongoose from 'mongoose'; import { faker } from '@faker-js/faker'; -import User from '../user/userModel'; +import User from './userModel'; import email from '../utils/email'; import jwt from 'jsonwebtoken'; import AppError from '../utils/appError'; diff --git a/src/user/userController.test.ts b/src/user/userController.integration.test.ts similarity index 100% rename from src/user/userController.test.ts rename to src/user/userController.integration.test.ts diff --git a/src/utils/apiHelper.test.ts b/src/utils/apiHelper.unit.test.ts similarity index 100% rename from src/utils/apiHelper.test.ts rename to src/utils/apiHelper.unit.test.ts diff --git a/src/utils/resultsHelper.test.ts b/src/utils/resultsHelper.unit.test.ts similarity index 100% rename from src/utils/resultsHelper.test.ts rename to src/utils/resultsHelper.unit.test.ts 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.test.ts b/src/utils/validator.unit.test.ts similarity index 100% rename from src/utils/validator.test.ts rename to src/utils/validator.unit.test.ts From 2e2a5050e192eb6b49205daa7ef5427dacdbb86e Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 21 Feb 2023 10:42:32 +0100 Subject: [PATCH 04/26] refactor: extracted User Repository --- package-lock.json | 16 +++++++++++-- package.json | 2 ++ src/user/userController.ts | 40 +++++++++++-------------------- src/user/userRepository.ts | 48 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 28 deletions(-) create mode 100644 src/user/userRepository.ts diff --git a/package-lock.json b/package-lock.json index 366f772..b287ced 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": { @@ -27,6 +27,7 @@ "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" @@ -34,6 +35,7 @@ "devDependencies": { "@faker-js/faker": "^7.2.0", "@types/express": "^4.17.17", + "@types/express-serve-static-core": "^4.17.33", "@types/jest": "^29.4.0", "@types/node": "^18.13.0", "@typescript-eslint/eslint-plugin": "^5.51.0", @@ -6105,6 +6107,11 @@ "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", @@ -11603,6 +11610,11 @@ "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", diff --git a/package.json b/package.json index f35f943..c06b00b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "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" @@ -41,6 +42,7 @@ "devDependencies": { "@faker-js/faker": "^7.2.0", "@types/express": "^4.17.17", + "@types/express-serve-static-core": "^4.17.33", "@types/jest": "^29.4.0", "@types/node": "^18.13.0", "@typescript-eslint/eslint-plugin": "^5.51.0", diff --git a/src/user/userController.ts b/src/user/userController.ts index 67f2e01..b9bdcd9 100644 --- a/src/user/userController.ts +++ b/src/user/userController.ts @@ -1,8 +1,8 @@ -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'; /** * Get all users @@ -10,7 +10,7 @@ import { findByIataCode } from '../airports/airportService'; * @param {*} res */ const getAllUsers = async (req, res) => { - const users = await User.find(); + const users = await UserRepository.all(); res.status(200).json({ status: 'success', @@ -38,13 +38,13 @@ const updateMe = catchAsync(async (req, res, next) => { 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 + // 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() - }); + // FIXME: le controller est directement couplé à MongoDB ... faudrait un service userService avec user.findMe, user.updateMe, .... + // est-ce que pour séparer en couche on aurait pas mieux fait de mettre tous mes models ensemble au lieu de mettre par métier? + const updatedUser = await UserRepository.updateOne(req.user.id, filteredBody); res.status(200).json({ status: 'success', @@ -59,9 +59,7 @@ const updateMe = catchAsync(async (req, res, next) => { */ const deleteMe = catchAsync(async (req, res) => { // 3) Update user - await User.findByIdAndUpdate(req.user.id, { - active: false, - }); + await UserRepository.deleteOne(req.user.id); res.status(204).json({ status: 'success', @@ -73,7 +71,7 @@ const deleteMe = catchAsync(async (req, res) => { * Get favorite airports for the currently logged-in user */ const getFavAirports = catchAsync(async (req, res) => { - const user = await User.findById(req.user.id); + const user = await UserRepository.findOne(req.user.id); res.status(200).json({ status: 'success', @@ -86,7 +84,7 @@ const getFavAirports = catchAsync(async (req, res) => { /** * Add a favorite airport to the list of favorite airports for that user */ -const addFavAirport = catchAsync(async (req, res, next) => { +const addFavAirportToUser = catchAsync(async (req, res, next) => { if (!req.body.airport) { return next(new AppError('Please specify an airport', 400)); } @@ -99,14 +97,9 @@ const addFavAirport = catchAsync(async (req, res, next) => { ); } - const updatedUser = await User.findByIdAndUpdate( + const updatedUser = await UserRepository.addFavAirportToUser( req.user.id, - { - $addToSet: { favAirports: req.body.airport }, - }, - { - new: true, - } + req.body.airport ); res.status(200).json({ @@ -133,14 +126,9 @@ const removeFavAirport = catchAsync(async (req, res, next) => { ); } - const updatedUser = await User.findByIdAndUpdate( + const updatedUser = await UserRepository.removeFavAirportFromUser( req.user.id, - { - $pullAll: { favAirports: [req.body.airport] }, - }, - { - new: true, - } + req.body.airport ); res.status(200).json({ @@ -152,7 +140,7 @@ const removeFavAirport = catchAsync(async (req, res, next) => { }); export = { - addFavAirport, + addFavAirport: addFavAirportToUser, deleteMe, getAllUsers, getFavAirports, diff --git a/src/user/userRepository.ts b/src/user/userRepository.ts new file mode 100644 index 0000000..1ad067b --- /dev/null +++ b/src/user/userRepository.ts @@ -0,0 +1,48 @@ +import User from './userModel'; + +export class UserRepository { + static updateOne = async (id, update) => { + 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) => { + return await User.findById(id); + }; + + static deleteOne = async (id) => { + await User.findByIdAndUpdate(id, { + active: false, + }); + }; + + static addFavAirportToUser = async (id, airport) => { + return await User.findByIdAndUpdate( + id, + { + $addToSet: { favAirports: airport }, + }, + { + new: true, + } + ); + }; + + static removeFavAirportFromUser = async (id, airport) => { + return await User.findByIdAndUpdate( + id, + { + $pullAll: { favAirports: [airport] }, + }, + { + new: true, + } + ); + }; +} From 5669402d9766b78f5cc54ab122b199bb276c0a9c Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 21 Feb 2023 11:05:03 +0100 Subject: [PATCH 05/26] refactor(repository): migrated countries to its own folder --- src/airports/airportService.ts | 2 +- src/{airports => countries}/countryService.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{airports => countries}/countryService.ts (100%) diff --git a/src/airports/airportService.ts b/src/airports/airportService.ts index 529a8b7..6cd30e1 100644 --- a/src/airports/airportService.ts +++ b/src/airports/airportService.ts @@ -1,5 +1,5 @@ /* eslint-disable no-unused-vars */ -import { countries } from './countryService'; +import { countries } from '../countries/countryService'; import fs from 'fs'; import path from 'path'; 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 From 9f915bdbe0fe233195607830b4b879ee06efc072 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 21 Feb 2023 12:10:43 +0100 Subject: [PATCH 06/26] refactor(service): extracted helper, model and repository for airport --- src/airports/airportHelper.ts | 53 ++++++++++ src/airports/airportHelper.unit.test.ts | 63 ++++++++++++ src/airports/airportModel.ts | 27 +++++ src/airports/airportRepository.ts | 33 +++++++ src/airports/airportService.ts | 119 ++++------------------- src/airports/airportService.unit.test.ts | 4 + 6 files changed, 198 insertions(+), 101 deletions(-) create mode 100644 src/airports/airportHelper.ts create mode 100644 src/airports/airportHelper.unit.test.ts create mode 100644 src/airports/airportModel.ts create mode 100644 src/airports/airportRepository.ts 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..8bb81e4 --- /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) => { + 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 6cd30e1..1868aa4 100644 --- a/src/airports/airportService.ts +++ b/src/airports/airportService.ts @@ -1,94 +1,9 @@ -/* eslint-disable no-unused-vars */ -import { countries } from '../countries/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 { + airportContainsQuerySearch, + airportStartsWithQuerySearch, + reencodeAirport, +} from './airportHelper'; +import { AirportRepository } from './airportRepository'; /** * Returns the first 10 results @@ -102,19 +17,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 +47,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 +56,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() diff --git a/src/airports/airportService.unit.test.ts b/src/airports/airportService.unit.test.ts index 8446bf2..b975063 100644 --- a/src/airports/airportService.unit.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); From 464ca6652f6719c96b56716c8f681a263be2fbca Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 21 Feb 2023 12:16:55 +0100 Subject: [PATCH 07/26] test: updated json config file to only collect coverage from ts files --- jest.config.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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", From e883e843d9e01b6184a129604932bc1f99e0722d Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Wed, 22 Feb 2023 10:59:27 +0100 Subject: [PATCH 08/26] refactor(service) : adding types in flightService --- src/common/types.ts | 24 ++++++ src/data/flightService.ts | 150 +++++++++++++++++++++++++++----------- 2 files changed, 131 insertions(+), 43 deletions(-) create mode 100644 src/common/types.ts diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..2261fc2 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,24 @@ +export enum WeekendLengthEnum { + LONG = 'long', + SHORT = 'short', +} + +export type RegularFlightsParams = { + origin: string; + departureDate: string; + returnDate: string; + adults: number; + children: number; + infants: number; +}; + +export type WeekendFlightsParams = { + origin: string; + destination: string; + departureDateFrom: string; + departureDateTo: string; + adults: number; + children: number; + infants: number; + weekendLength?: WeekendLengthEnum; +}; diff --git a/src/data/flightService.ts b/src/data/flightService.ts index e98dd77..0db2bdc 100644 --- a/src/data/flightService.ts +++ b/src/data/flightService.ts @@ -1,29 +1,91 @@ import axios from 'axios'; import helper from '../utils/apiHelper'; import { setupCache } from 'axios-cache-interceptor'; +import { + RegularFlightsParams, + WeekendFlightsParams, + WeekendLengthEnum, +} from '../common/types'; + +type IataCode = string; +type DateDDMMYYYY = string; + +// 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', +}; + +enum DayOfWeek { + SUNDAY = 0, + MONDAY = 1, + TUESDAY = 2, + WEDNESDAY = 3, + THURSDAY = 4, + FRIDAY = 5, + SATURDAY = 6, +} + +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'; +}; + +type KiwiAPIWeekendParams = { + fly_to: IataCode; + + fly_days?: DayOfWeek[]; + ret_fly_days?: DayOfWeek[]; + nights_in_dst_from?: number; + nights_in_dst_to?: number; +} & KiwiBaseAPIParams; + +type KiwiAPIAllDaysParams = { + fly_to: 'anywhere'; + returnFrom?: DateDDMMYYYY; + returnTo?: DateDDMMYYYY; + ret_from_diff_airport?: number; + ret_to_diff_airport?: number; + one_for_city?: number; +} & KiwiBaseAPIParams; setupCache(axios, { ttl: 1000 * 60 * 15 }); //15 minutes // FIXME: added 'any' to allow compiler -const getWeekendFlights = async (params) => { +const getWeekendFlights = async (params: WeekendFlightsParams) => { // 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 = { fly_from: params.origin, fly_to: params.destination, dateFrom: params.departureDateFrom, @@ -31,22 +93,27 @@ const getWeekendFlights = async (params) => { adults: params.adults, children: params.children, infants: params.infants, + ...DEFAULT_KIWI_API_PARAMS, }; - 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, }; @@ -77,35 +144,32 @@ const getWeekendFlights = async (params) => { }; // FIXME: better handle errors -const getFlights = async (params) => { +const getFlights = async (params: RegularFlightsParams) => { 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, + children: params.children, + infants: params.infants, + 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', 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, - }, + params: axiosParams, }); if (response && response.data) { From 1df0837f3213bef32b9794b346b32c26b948c85e Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Wed, 22 Feb 2023 12:43:53 +0100 Subject: [PATCH 09/26] refactor(middleware): extracted validator middleware filter params function into its own unit class and test --- src/common/interfaces.ts | 8 ++ src/common/types.ts | 18 +++ src/config.ts | 7 +- src/destinations/destinationsRoutes.ts | 19 +-- src/middleware/validator/validatorHelper.ts | 49 ++++++++ .../validator/validatorHelper.unit.test.ts | 47 ++++++++ .../validatorService.integration.test.ts | 113 ++++-------------- .../validator}/validatorService.ts | 54 +++------ src/views/viewRoutes.ts | 14 +-- 9 files changed, 183 insertions(+), 146 deletions(-) create mode 100644 src/common/interfaces.ts create mode 100644 src/middleware/validator/validatorHelper.ts create mode 100644 src/middleware/validator/validatorHelper.unit.test.ts rename src/{common => middleware/validator}/validatorService.integration.test.ts (72%) rename src/{common => middleware/validator}/validatorService.ts (84%) diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts new file mode 100644 index 0000000..9292eb0 --- /dev/null +++ b/src/common/interfaces.ts @@ -0,0 +1,8 @@ +import { Request } from 'express'; +import { Query } from 'express-serve-static-core'; + +export interface TypedRequestQueryWithFilter + extends Request { + filter?: K; + query: T; +} diff --git a/src/common/types.ts b/src/common/types.ts index 2261fc2..206aa57 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -22,3 +22,21 @@ export type WeekendFlightsParams = { infants: number; weekendLength?: WeekendLengthEnum; }; + +export type QueryParams = { + 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; +}; 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/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/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..408294c --- /dev/null +++ b/src/middleware/validator/validatorHelper.unit.test.ts @@ -0,0 +1,47 @@ +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 = { origin: 'MAD', sort: 'price' }; + + const filter = getFilterParamsFromQueryParams(query); + expect(filter.sort).toBe('price'); + }); + + test("should remove param 'sort' from req.query", function () { + const query = { origin: 'MAD', 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 = {}; + + 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 = { + origin: 'MAD', + 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.integration.test.ts b/src/middleware/validator/validatorService.integration.test.ts similarity index 72% rename from src/common/validatorService.integration.test.ts rename to src/middleware/validator/validatorService.integration.test.ts index dea30f8..00f6ab2 100644 --- a/src/common/validatorService.integration.test.ts +++ b/src/middleware/validator/validatorService.integration.test.ts @@ -1,74 +1,11 @@ -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'; 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); - }); - }); - describe('validate middleware', function () { let req, res, next; beforeEach(() => { @@ -104,7 +41,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend(req, res, 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)); }); @@ -117,7 +54,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), @@ -133,7 +70,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)destination/), @@ -149,7 +86,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDateFrom/), @@ -165,7 +102,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDateTo/), @@ -182,7 +119,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -201,7 +138,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin(req, res, next); expect(next).not.toHaveBeenCalledWith(expect.any(AppError)); }); test('should call next with an AppError when origin param is missing', function () { @@ -212,7 +149,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), @@ -228,7 +165,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDate/), @@ -244,7 +181,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)returnDate/), @@ -260,7 +197,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -280,7 +217,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).not.toHaveBeenCalledWith(expect.any(AppError)); }); test('should call next with an AppError when origin param is missing', function () { @@ -291,7 +228,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), @@ -307,7 +244,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDate/), @@ -323,7 +260,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)returnDate/), @@ -339,7 +276,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -357,7 +294,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)adults/), @@ -375,7 +312,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)children/), @@ -393,7 +330,7 @@ describe('ValidatorService', function () { }, }; - validatorService.validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins(req, res, next); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)infants/), diff --git a/src/common/validatorService.ts b/src/middleware/validator/validatorService.ts similarity index 84% rename from src/common/validatorService.ts rename to src/middleware/validator/validatorService.ts index 30847aa..3418fb9 100644 --- a/src/common/validatorService.ts +++ b/src/middleware/validator/validatorService.ts @@ -1,16 +1,10 @@ -import validator from '../utils/validator'; +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'; - -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' }, -]; +import AppError from '../../utils/appError'; +import { NextFunction, Response } from 'express'; +import { TypedRequestQueryWithFilter } from '../../common/interfaces'; +import { FilterParams, QueryParams } from '../../common/types'; +import { getFilterParamsFromQueryParams } from './validatorHelper'; const ONE_CITYCODE_PARAM_MODEL = { required: true, @@ -45,23 +39,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,7 +56,7 @@ const filterParams = (req, res, next) => { * @param {*} res * @param {*} next */ -const validateRequestParamsWeekend = (req, res, next) => { +export const validateRequestParamsWeekend = (req, res, next) => { const requestModelParams = [ { name: 'origin', ...ONE_CITYCODE_PARAM_MODEL }, { @@ -113,7 +97,7 @@ const validateRequestParamsWeekend = (req, res, next) => { * @param {*} res * @param {*} next */ -const validateRequestParamsManyOrigins = (req, res, next) => { +export const validateRequestParamsManyOrigins = (req, res, next) => { // 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 = [ { name: 'origin', ...SEVERAL_CITYCODES_PARAM_MODEL }, @@ -185,7 +169,7 @@ const validateRequestParamsManyOrigins = (req, res, next) => { * @param {*} res * @param {*} next */ -const validateRequestParamsOneOrigin = (req, res, next) => { +export const validateRequestParamsOneOrigin = (req, res, next) => { // 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 = [ { @@ -264,11 +248,3 @@ const checkMissingParams = (modelParams, query, next) => { ) ); }; - -// TODO: validate request param for cheapest weekend requests -export = { - validateRequestParamsManyOrigins, - validateRequestParamsOneOrigin, - validateRequestParamsWeekend, - filterParams, -}; 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); From f4ad3e34707744cb50668672ccd7f34fceb6bbc8 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Thu, 23 Feb 2023 07:34:24 +0100 Subject: [PATCH 10/26] fix(service): flightService tests were not compiling anymore. We removed adults children and infants as required params in the corresponding types --- src/common/types.ts | 12 ++--- src/data/flightService.integration.test.ts | 58 ++-------------------- src/data/flightService.ts | 23 +++++---- src/utils/apiHelper.ts | 1 + 4 files changed, 23 insertions(+), 71 deletions(-) diff --git a/src/common/types.ts b/src/common/types.ts index 206aa57..314353e 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -7,9 +7,9 @@ export type RegularFlightsParams = { origin: string; departureDate: string; returnDate: string; - adults: number; - children: number; - infants: number; + adults?: number; + children?: number; + infants?: number; }; export type WeekendFlightsParams = { @@ -17,9 +17,9 @@ export type WeekendFlightsParams = { destination: string; departureDateFrom: string; departureDateTo: string; - adults: number; - children: number; - infants: number; + adults?: number; + children?: number; + infants?: number; weekendLength?: WeekendLengthEnum; }; diff --git a/src/data/flightService.integration.test.ts b/src/data/flightService.integration.test.ts index 880576e..65c54df 100644 --- a/src/data/flightService.integration.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( @@ -84,7 +58,7 @@ maybe('Flight Service - Integration with KIWI API', function () { const prepareSpy = jest.spyOn(helper, 'prepareAxiosParams'); await flightService.getWeekendFlights({ ...FLIGHT_API_PARAMS_FIXTURE_WEEKEND, - weekendLength: 'long', + weekendLength: WeekendLengthEnum.LONG, }); expect(prepareSpy).toHaveBeenCalledWith( @@ -105,7 +79,7 @@ maybe('Flight Service - Integration with KIWI API', function () { const prepareSpy = jest.spyOn(helper, 'prepareAxiosParams'); 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 0db2bdc..e45770b 100644 --- a/src/data/flightService.ts +++ b/src/data/flightService.ts @@ -32,6 +32,9 @@ const DEFAULT_KIWI_API_PARAMS: Partial = { limit: 1000, flight_type: 'round', }; +const DEFAULT_ADULTS_PARAM = 1; +const DEFAULT_CHILDREN_PARAM = 0; +const DEFAULT_INFANTS_PARAM = 0; enum DayOfWeek { SUNDAY = 0, @@ -47,9 +50,9 @@ type KiwiBaseAPIParams = { fly_from: IataCode; dateFrom: DateDDMMYYYY; dateTo: DateDDMMYYYY; - adults?: number; - children?: number; - infants?: number; + adults: number; + children: number; + infants: number; max_stopovers?: number; partner_market?: string; lang?: string; @@ -86,14 +89,14 @@ const getWeekendFlights = async (params: WeekendFlightsParams) => { // FIXME: added 'any' to allow compiler, otherwise it fails. Please create a type or interface. 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, - ...DEFAULT_KIWI_API_PARAMS, + adults: params.adults ?? DEFAULT_ADULTS_PARAM, + children: params.children ?? DEFAULT_CHILDREN_PARAM, + infants: params.infants ?? DEFAULT_INFANTS_PARAM, }; if (params.weekendLength === WeekendLengthEnum.LONG) { @@ -154,9 +157,9 @@ const getFlights = async (params: RegularFlightsParams) => { dateTo: params.departureDate, returnFrom: params.returnDate, returnTo: params.returnDate, - 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, ret_from_diff_airport: 0, ret_to_diff_airport: 0, one_for_city: 1, diff --git a/src/utils/apiHelper.ts b/src/utils/apiHelper.ts index de3431d..7b6e141 100644 --- a/src/utils/apiHelper.ts +++ b/src/utils/apiHelper.ts @@ -247,6 +247,7 @@ const prepareAxiosParams = (params) => { * @param {*} params input params * @returns */ +// 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) => { return { ...params, From 76656afc717d21fcc21d62a054a0255c077dc45d Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Mon, 27 Feb 2023 08:44:10 +0100 Subject: [PATCH 11/26] refactor(service) : adding types and interfaces for destinations and itineraries --- src/common/interfaces.ts | 12 +- src/common/types.ts | 79 +++++- src/data/flightService.ts | 116 +++++---- ...destinationsController.integration.test.ts | 236 ++++++------------ src/destinations/destinationsController.ts | 125 ++++++---- .../validator/validatorHelper.unit.test.ts | 25 +- src/tests/endtoend.test.ts | 9 +- src/utils/apiHelper.ts | 125 +++++++--- src/utils/apiHelper.unit.test.ts | 73 ++---- src/utils/fixtures.ts | 191 +------------- 10 files changed, 438 insertions(+), 553 deletions(-) diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index 9292eb0..d8244ea 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -1,8 +1,14 @@ -import { Request } from 'express'; -import { Query } from 'express-serve-static-core'; +/* 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 +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 index 314353e..06e8203 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,3 +1,59 @@ +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 }; + price: number; + route: Route[]; + deep_link: URL; + local_arrival: ISODate; + utc_arrival: ISODate; + local_departure: ISODate; + utc_departure: ISODate; +}; + +export type CommonDestination = { + cityTo: string; + flights: Itinerary[]; + countryTo: string; + cityCodeTo: string; + price: number; + distance: number; + totalDurationDepartureInMinutes: number; + totalDurationReturnInMinutes: number; +}; + +export type Route = { + 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; +}; + +// FIXME: KiwiRoute should be used in Kiwi Itinerary ... +export type KiwiRoute = Route; +export type KiwiItinerary = Itinerary & { route: KiwiRoute[] }; + +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', @@ -7,21 +63,21 @@ export type RegularFlightsParams = { origin: string; departureDate: string; returnDate: string; - adults?: number; - children?: number; - infants?: number; -}; + adults?: string; + children?: string; + infants?: string; +} & QueryParams; export type WeekendFlightsParams = { origin: string; destination: string; departureDateFrom: string; departureDateTo: string; - adults?: number; - children?: number; - infants?: number; + adults?: string; + children?: string; + infants?: string; weekendLength?: WeekendLengthEnum; -}; +} & QueryParams; export type QueryParams = { sort?: string; @@ -40,3 +96,10 @@ export type FilterParams = { priceFrom?: number; priceTo?: number; }; + +export type APISuccessAnswer = { + status: 'success'; + totalResults: number; + shownResults: number; + data: object[]; +}; diff --git a/src/data/flightService.ts b/src/data/flightService.ts index e45770b..5084641 100644 --- a/src/data/flightService.ts +++ b/src/data/flightService.ts @@ -2,14 +2,15 @@ import axios from 'axios'; import helper from '../utils/apiHelper'; import { setupCache } from 'axios-cache-interceptor'; import { + DateDDMMYYYY, + IataCode, + Itinerary, + KiwiItinerary, RegularFlightsParams, WeekendFlightsParams, WeekendLengthEnum, } from '../common/types'; -type IataCode = string; -type DateDDMMYYYY = string; - // type DefaultKiwiAPIParams = { // max_stopovers: 2; // partner_market: 'fr'; @@ -80,8 +81,58 @@ type KiwiAPIAllDaysParams = { 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: WeekendFlightsParams) => { +const getWeekendFlights = async ( + params: WeekendFlightsParams +): Promise => { // var flyingDaysParams = new URLSearchParams(); // flyingDaysParams.append('fly_days', 4); // flyingDaysParams.append('fly_days', 5); @@ -94,9 +145,9 @@ const getWeekendFlights = async (params: WeekendFlightsParams) => { fly_to: params.destination, dateFrom: params.departureDateFrom, dateTo: params.departureDateTo, - adults: params.adults ?? DEFAULT_ADULTS_PARAM, - children: params.children ?? DEFAULT_CHILDREN_PARAM, - infants: params.infants ?? DEFAULT_INFANTS_PARAM, + adults: +(params.adults ?? DEFAULT_ADULTS_PARAM), + children: +(params.children ?? DEFAULT_CHILDREN_PARAM), + infants: +(params.infants ?? DEFAULT_INFANTS_PARAM), }; if (params.weekendLength === WeekendLengthEnum.LONG) { @@ -123,6 +174,9 @@ const getWeekendFlights = async (params: WeekendFlightsParams) => { } try { + 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.prepareAxiosParams(axiosParams); const response = await axios.get( `${process.env.KIWI_URL}?${preparedAxiosParams.toString()}`, @@ -146,50 +200,4 @@ const getWeekendFlights = async (params: WeekendFlightsParams) => { } }; -// FIXME: better handle errors -const getFlights = async (params: RegularFlightsParams) => { - 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', - const response = await axios.get(process.env.KIWI_URL, { - headers: { - apikey: process.env.KIWI_API_KEY, - }, - params: axiosParams, - }); - - 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; - } -}; - -export = { - getWeekendFlights, - getFlights, -}; +export = { getFlights, getWeekendFlights }; diff --git a/src/destinations/destinationsController.integration.test.ts b/src/destinations/destinationsController.integration.test.ts index 2f29347..c711583 100644 --- a/src/destinations/destinationsController.integration.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,44 @@ import { } from '../utils/fixtures'; import flightService from '../data/flightService'; import AppError from '../utils/appError'; -// import AppError from '../utils/appError'; + +import { Request, NextFunction } from 'express-serve-static-core'; +import { + APISuccessResponse, + TypedRequestQueryWithFilter, +} from '../common/interfaces'; +import { + Itinerary, + RegularFlightsParams, + WeekendFlightsParams, +} from '../common/types'; // 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: Partial>, + 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); - req = { - query: CHEAPEST_DESTINATION_QUERY_FIXTURE, - }; + req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE }; 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(); }); @@ -83,7 +95,9 @@ describe('Destinations Controller', function () { }); }); describe('error cases', function () { - let res, next; + let req: Partial>, + res: Partial & { data: Itinerary[] }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -92,48 +106,13 @@ 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 = { - query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, - }; + req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN }; await destinationsController.getCheapestDestinations(req, res, next); @@ -152,9 +131,11 @@ describe('Destinations Controller', function () { describe('getCommonDestinations', function () { describe('success cases', function () { - let req, res, next; + let req: Partial>, + res: Partial & { data: Itinerary[] }, + next: NextFunction; - let getFlightsSpy; + let getFlightsSpy: jest.SpyInstance; beforeEach(() => { getFlightsSpy = jest .spyOn(flightService, 'getFlights') @@ -163,9 +144,7 @@ describe('Destinations Controller', function () { .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD) .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU); - req = { - query: COMMON_DESTINATION_QUERY_FIXTURE, - }; + req = { query: COMMON_DESTINATION_QUERY_FIXTURE }; res = { status: jest.fn().mockImplementation(function () { @@ -174,7 +153,7 @@ describe('Destinations Controller', function () { json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); @@ -190,6 +169,7 @@ describe('Destinations Controller', function () { ); }); + // 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); expect(flightService.getFlights).toHaveBeenNthCalledWith( @@ -219,9 +199,25 @@ 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, 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,42 +226,13 @@ 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, - }; + req = { query: COMMON_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN }; await destinationsController.getCommonDestinations(req, res, next); expect(next).toHaveBeenCalledWith(expect.any(AppError)); @@ -276,48 +243,22 @@ describe('Destinations Controller', function () { }) ); }); - - 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'), - }) - ); - }); - - 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: Partial>, + 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); - req = { - query: CHEAPEST_WEEKEND_QUERY_FIXTURE, - }; + req = { query: CHEAPEST_WEEKEND_QUERY_FIXTURE }; res = { status: jest.fn().mockImplementation(function () { @@ -326,7 +267,7 @@ describe('Destinations Controller', function () { json: jest.fn().mockImplementation(function (obj) { this.data = obj.data; }), - data: null, + data: [], }; next = jest.fn(); }); @@ -334,25 +275,6 @@ 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); @@ -389,9 +311,24 @@ 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, next); + + expect(flightService.getWeekendFlights).toHaveBeenCalledWith( + expect.objectContaining({ + adults: 1, + children: 0, + infants: 0, + }) + ); + }); }); describe('error cases', function () { - let res, next; + let req: Partial>, + res: Partial & { data: Itinerary[] }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -400,32 +337,13 @@ 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 = { - query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, - }; + req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN }; await destinationsController.getCheapestWeekend(req, res, next); diff --git a/src/destinations/destinationsController.ts b/src/destinations/destinationsController.ts index d42a021..28291cf 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,27 @@ 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); - const flights = await flightService.getFlights(params); + const flights = await flightService.getFlights(params); - let itineraries = flights.map(helper.cleanItineraryData); - const totalResults = itineraries.length; - itineraries = resultsHelper.applyFilters(itineraries, req.filter); + let itineraries = flights.map(helper.cleanItineraryData); + 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 +50,64 @@ 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 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, + }); + } +); -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); - const flights = await flightService.getWeekendFlights(params); + const flights = await flightService.getWeekendFlights(params); - let itineraries = flights.map(helper.cleanItineraryData); - const totalResults = itineraries.length; + let itineraries = flights.map(helper.cleanItineraryData); + 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/middleware/validator/validatorHelper.unit.test.ts b/src/middleware/validator/validatorHelper.unit.test.ts index 408294c..bf68a37 100644 --- a/src/middleware/validator/validatorHelper.unit.test.ts +++ b/src/middleware/validator/validatorHelper.unit.test.ts @@ -1,3 +1,4 @@ +import { RegularFlightsParams } from '../../common/types'; import { DEFAULT_FIRST_PAGE_OF_RESULT, DEFAULT_SORT_FIELD, @@ -8,21 +9,35 @@ import { getFilterParamsFromQueryParams } from './validatorHelper'; describe('Validator Helper', () => { describe('getFilterParamsFromQueryParams', () => { test("should return an object with param 'sort'", function () { - const query = { origin: 'MAD', sort: 'price' }; + 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 = { origin: 'MAD', sort: 'price' }; + 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 = {}; + const query: RegularFlightsParams = { + origin: 'MAD', + departureDate: '23032023', + returnDate: '26032023', + }; const filter = getFilterParamsFromQueryParams(query); expect(filter.sort).toBe(DEFAULT_SORT_FIELD); @@ -31,8 +46,10 @@ describe('Validator Helper', () => { }); test("should return an object with params 'maxConnections', 'priceFrom', 'priceTo' when present", function () { - const query = { + const query: RegularFlightsParams = { origin: 'MAD', + departureDate: '23032023', + returnDate: '26032023', maxConnections: '2', priceFrom: '32', priceTo: '56', diff --git a/src/tests/endtoend.test.ts b/src/tests/endtoend.test.ts index 9d0c4a0..b2e20e0 100644 --- a/src/tests/endtoend.test.ts +++ b/src/tests/endtoend.test.ts @@ -29,7 +29,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 +41,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); @@ -190,6 +190,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', () => { @@ -241,6 +243,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 +336,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/utils/apiHelper.ts b/src/utils/apiHelper.ts index 7b6e141..2d063a8 100644 --- a/src/utils/apiHelper.ts +++ b/src/utils/apiHelper.ts @@ -1,4 +1,11 @@ import { Settings, Duration, DateTime } from 'luxon'; +import { + CommonDestination, + Itinerary, + KiwiItinerary, + KiwiRoute, + Route, +} from '../common/types'; Settings.defaultLocale = 'fr'; /** @@ -42,10 +49,14 @@ 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) => { +const prepareItineraryData = ( + dest, + itineraries: Itinerary[], + 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 itinerary: 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 @@ -91,37 +102,74 @@ const prepareItineraryData = (dest, itineraries, passengersPerOrigin) => { return itinerary; }; +const convertKiwiItineraryToItinerary = (input: KiwiItinerary): Itinerary => { + return { + flyFrom: input.flyFrom, + flyTo: input.flyTo, + cityFrom: input.cityFrom, + cityCodeFrom: input.cityCodeFrom, + cityTo: input.cityTo, + cityCodeTo: input.cityCodeTo, + countryTo: input.countryTo, + distance: input.distance, + duration: input.duration, + price: input.price, + deep_link: input.deep_link, + local_arrival: input.local_arrival, + utc_arrival: input.utc_arrival, + local_departure: input.local_departure, + utc_departure: input.utc_departure, + route: input.route.map(convertKiwiRouteToRoute), + }; +}; + +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) => { +const cleanItineraryData = (input: 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; + // 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; + // 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; }); @@ -176,21 +224,21 @@ const cleanItineraryData = (input) => { 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; + // 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; }; @@ -336,6 +384,7 @@ const prepareSeveralOriginAPIParams = (params) => { export = { cleanItineraryData, + convertKiwiItineraryToItinerary, extractConnections, filterDestinationCities, isCommonDestination, diff --git a/src/utils/apiHelper.unit.test.ts b/src/utils/apiHelper.unit.test.ts index d08aa86..013cb89 100644 --- a/src/utils/apiHelper.unit.test.ts +++ b/src/utils/apiHelper.unit.test.ts @@ -1,21 +1,29 @@ import helper from './apiHelper'; import apiOneWayAnswer from '../datasets/fixtures/apiOneWayAnswer.json'; import apiReturnAnswer from '../datasets/fixtures/apiReturnAnswer.json'; +import { Itinerary, KiwiItinerary } 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'); }); - + }); + describe('cleanItineraryData', function () { test('should normalize data from one-way itinerary', function () { const itinerary = apiOneWayAnswer.data[0]; const cleaned = helper.cleanItineraryData(itinerary); @@ -55,51 +63,15 @@ describe('API Helper', function () { 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 }, - }, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD, ]; 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( 'Ibiza', @@ -107,10 +79,11 @@ describe('API Helper', function () { mapPassengersPerOrigin ); - expect(itinerary.countryTo).toBe('Spain'); + expect(itinerary.countryTo).toBe('Espagne'); expect(itinerary.cityCodeTo).toBe('IBZ'); expect(itinerary.flights).toHaveLength(3); }); + test('should compute all info about a set of flights', () => { const itinerary = helper.prepareItineraryData( 'Ibiza', @@ -118,7 +91,11 @@ describe('API Helper', function () { 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 + ); }); }); diff --git a/src/utils/fixtures.ts b/src/utils/fixtures.ts index 66a1b3b..265fb63 100644 --- a/src/utils/fixtures.ts +++ b/src/utils/fixtures.ts @@ -90,24 +90,16 @@ 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, @@ -115,102 +107,40 @@ const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ 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'], 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, @@ -218,102 +148,39 @@ const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ 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'], 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, @@ -321,78 +188,23 @@ const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ 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'], 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 +232,7 @@ const COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD = [ }, type_flights: ['deprecated'], nightsInDest: 2, + quality: 381.3329, distance: 459.95, duration: { From 7d2d3c7e3010a8ef433ef0ca0a9d2bef2841313c Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Mon, 27 Feb 2023 08:51:35 +0100 Subject: [PATCH 12/26] refactor(service) : adding missing types for airport --- src/airports/airportModel.ts | 2 +- src/airports/airportService.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/airports/airportModel.ts b/src/airports/airportModel.ts index 8bb81e4..67d445e 100644 --- a/src/airports/airportModel.ts +++ b/src/airports/airportModel.ts @@ -17,7 +17,7 @@ const parsedAirports = JSON.parse( ) ); -const filterAirportFields = (airport) => { +const filterAirportFields = (airport: Partial) => { const { iata_code, iso_country, municipality, name, type } = airport; return { iata_code, iso_country, municipality, name, type }; }; diff --git a/src/airports/airportService.ts b/src/airports/airportService.ts index 1868aa4..35d778b 100644 --- a/src/airports/airportService.ts +++ b/src/airports/airportService.ts @@ -1,3 +1,4 @@ +import { IataCode } from '../common/types'; import { airportContainsQuerySearch, airportStartsWithQuerySearch, @@ -82,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}`; From 425e5c72238925bc72bcffc0258a8520aa28e0cd Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 28 Feb 2023 05:06:41 +0100 Subject: [PATCH 13/26] refactor(service) : adding missing types for destination --- package-lock.json | 56 +++++++++++++ package.json | 4 + src/destinations/destinationsService.ts | 22 ++--- src/utils/apiHelper.ts | 51 ++++++++++-- src/utils/apiHelper.unit.test.ts | 102 +++++++++++++++++++++++- 5 files changed, 215 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index b287ced..6709fba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "helmet": "^5.0.2", "hpp": "^0.2.3", "jsonwebtoken": "^8.5.1", + "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", "morgan": "^1.10.0", @@ -37,6 +38,9 @@ "@types/express": "^4.17.17", "@types/express-serve-static-core": "^4.17.33", "@types/jest": "^29.4.0", + "@types/lodash": "^4.14.191", + "@types/lodash.groupby": "^4.6.7", + "@types/luxon": "^3.2.0", "@types/node": "^18.13.0", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", @@ -1455,6 +1459,27 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "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.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", @@ -4894,6 +4919,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "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.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8171,6 +8201,27 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "@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.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", @@ -10697,6 +10748,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "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.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/package.json b/package.json index c06b00b..a7f2272 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "helmet": "^5.0.2", "hpp": "^0.2.3", "jsonwebtoken": "^8.5.1", + "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", "morgan": "^1.10.0", @@ -44,6 +45,9 @@ "@types/express": "^4.17.17", "@types/express-serve-static-core": "^4.17.33", "@types/jest": "^29.4.0", + "@types/lodash": "^4.14.191", + "@types/lodash.groupby": "^4.6.7", + "@types/luxon": "^3.2.0", "@types/node": "^18.13.0", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", diff --git a/src/destinations/destinationsService.ts b/src/destinations/destinationsService.ts index acc6df2..c6ddd47 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 diff --git a/src/utils/apiHelper.ts b/src/utils/apiHelper.ts index 2d063a8..f52b001 100644 --- a/src/utils/apiHelper.ts +++ b/src/utils/apiHelper.ts @@ -1,13 +1,31 @@ import { Settings, Duration, DateTime } from 'luxon'; +// import groupByToMap from 'core-js-pure/actual/array/group-by-to-map'; +import groupBy from 'lodash.groupby'; import { CommonDestination, Itinerary, KiwiItinerary, KiwiRoute, + RegularFlightsParams, Route, } 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. @@ -359,29 +377,46 @@ 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, @@ -393,4 +428,6 @@ export = { prepareDefaultAPIParams, prepareSeveralOriginAPIParams, prepareSeveralOriginAPIParamsFromView, + groupByDestination, + getMapPassengersPerOrigin, }; diff --git a/src/utils/apiHelper.unit.test.ts b/src/utils/apiHelper.unit.test.ts index 013cb89..f8a2fa0 100644 --- a/src/utils/apiHelper.unit.test.ts +++ b/src/utils/apiHelper.unit.test.ts @@ -1,7 +1,11 @@ import helper from './apiHelper'; import apiOneWayAnswer from '../datasets/fixtures/apiOneWayAnswer.json'; import apiReturnAnswer from '../datasets/fixtures/apiReturnAnswer.json'; -import { Itinerary, KiwiItinerary } from '../common/types'; +import { + Itinerary, + KiwiItinerary, + RegularFlightsParams, +} from '../common/types'; import { COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD, COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU, @@ -99,6 +103,94 @@ describe('API Helper', function () { }); }); + 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 itineraries = [ + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BRU, + ...COMMON_DESTINATION_KIWI_RESULT_FIXTURE_BOD, + ]; + + 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 + ); + }); + }); + describe('filterDestinationCities', function () { const destinations = new Map(); destinations.set('Ibiza', [ @@ -232,8 +324,10 @@ describe('API Helper', function () { 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); @@ -244,6 +338,8 @@ 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); @@ -256,6 +352,8 @@ describe('API Helper', function () { 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', }; From 5c791f734a940d4cd30d4713157122f63c9ad16c Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 28 Feb 2023 12:40:52 +0100 Subject: [PATCH 14/26] refactor(service) : adding missing types for middleware/validator --- package-lock.json | 13 ++ package.json | 1 + src/common/types.ts | 8 + .../validatorService.integration.test.ts | 163 ++++++++++++++---- src/middleware/validator/validatorService.ts | 55 ++++-- src/utils/validator.ts | 15 +- 6 files changed, 196 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6709fba..7ac6c51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@types/lodash.groupby": "^4.6.7", "@types/luxon": "^3.2.0", "@types/node": "^18.13.0", + "@types/validator": "^13.7.12", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", "concurrently": "^7.6.0", @@ -1531,6 +1532,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "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", @@ -8273,6 +8280,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "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", diff --git a/package.json b/package.json index a7f2272..8280b3a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/lodash.groupby": "^4.6.7", "@types/luxon": "^3.2.0", "@types/node": "^18.13.0", + "@types/validator": "^13.7.12", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", "concurrently": "^7.6.0", diff --git a/src/common/types.ts b/src/common/types.ts index 06e8203..537e01b 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -80,6 +80,7 @@ export type WeekendFlightsParams = { } & QueryParams; export type QueryParams = { + [key: string]: string; // necessary to dynamically check properties in validator. sort?: string; limit?: string; page?: string; @@ -103,3 +104,10 @@ export type APISuccessAnswer = { shownResults: number; data: object[]; }; + +export type BaseParamModel = { + required: boolean; + typeCheck: (str: string) => boolean; + errorMsg: string; +}; +export type ParamModel = BaseParamModel & { name: string }; diff --git a/src/middleware/validator/validatorService.integration.test.ts b/src/middleware/validator/validatorService.integration.test.ts index 00f6ab2..4c22c19 100644 --- a/src/middleware/validator/validatorService.integration.test.ts +++ b/src/middleware/validator/validatorService.integration.test.ts @@ -4,10 +4,19 @@ import { 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 () { + // 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 () { @@ -19,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: { @@ -41,36 +48,50 @@ describe('ValidatorService', function () { }, }; - 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, }; - 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, }; - validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)destination/), @@ -83,10 +104,14 @@ describe('ValidatorService', function () { origin: 'BXL', destination: 'MAD', departureDateTo: '29/06/2022', - }, + } as WeekendFlightsParams, }; - validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDateFrom/), @@ -99,10 +124,14 @@ describe('ValidatorService', function () { origin: 'BXL', destination: 'MAD', departureDateFrom: '29/06/2022', - }, + } as WeekendFlightsParams, }; - validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDateTo/), @@ -119,7 +148,11 @@ describe('ValidatorService', function () { }, }; - validateRequestParamsWeekend(req, res, next); + validateRequestParamsWeekend( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -129,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, }; - 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 () { @@ -146,10 +184,14 @@ describe('ValidatorService', function () { query: { departureDate: '22/06/2022', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), @@ -162,10 +204,14 @@ describe('ValidatorService', function () { query: { origin: 'BXL', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDate/), @@ -178,10 +224,14 @@ describe('ValidatorService', function () { query: { origin: 'BXL', departureDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)returnDate/), @@ -197,7 +247,11 @@ describe('ValidatorService', function () { }, }; - validateRequestParamsOneOrigin(req, res, next); + validateRequestParamsOneOrigin( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -207,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: { @@ -217,7 +272,11 @@ describe('ValidatorService', function () { }, }; - 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 () { @@ -225,10 +284,14 @@ describe('ValidatorService', function () { query: { departureDate: '22/06/2022', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)origin/), @@ -241,10 +304,14 @@ describe('ValidatorService', function () { query: { origin: 'MAD,BXL', returnDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)departureDate/), @@ -257,10 +324,14 @@ describe('ValidatorService', function () { query: { origin: 'MAD,BXL', departureDate: '29/06/2022', - }, + } as RegularFlightsParams, }; - validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/missing(.*)returnDate/), @@ -276,7 +347,11 @@ describe('ValidatorService', function () { }, }; - validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/expected(.*)origin/), @@ -294,7 +369,11 @@ describe('ValidatorService', function () { }, }; - validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)adults/), @@ -312,7 +391,11 @@ describe('ValidatorService', function () { }, }; - validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)children/), @@ -330,7 +413,11 @@ describe('ValidatorService', function () { }, }; - validateRequestParamsManyOrigins(req, res, next); + validateRequestParamsManyOrigins( + req as TypedRequestQueryWithFilter, + res as Response, + next + ); expect(next).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/same(.*)infants/), @@ -339,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/middleware/validator/validatorService.ts b/src/middleware/validator/validatorService.ts index 3418fb9..d2f3620 100644 --- a/src/middleware/validator/validatorService.ts +++ b/src/middleware/validator/validatorService.ts @@ -1,14 +1,21 @@ import validator from '../../utils/validator'; -import { isAlpha, isDate, isNumeric } from 'validator'; +import validatorJs from 'validator'; import AppError from '../../utils/appError'; import { NextFunction, Response } from 'express'; import { TypedRequestQueryWithFilter } from '../../common/interfaces'; -import { FilterParams, QueryParams } from '../../common/types'; +import { + BaseParamModel, + FilterParams, + ParamModel, + QueryParams, + RegularFlightsParams, + WeekendFlightsParams, +} from '../../common/types'; import { getFilterParamsFromQueryParams } from './validatorHelper'; -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 = { @@ -18,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 = { @@ -56,8 +63,12 @@ export const filterParams = ( * @param {*} res * @param {*} next */ -export 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', @@ -97,9 +108,13 @@ export const validateRequestParamsWeekend = (req, res, next) => { * @param {*} res * @param {*} next */ -export 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', @@ -169,9 +184,13 @@ export const validateRequestParamsManyOrigins = (req, res, next) => { * @param {*} res * @param {*} next */ -export 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, @@ -212,7 +231,11 @@ export 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 @@ -236,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( 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]) From 8ee0e5592a79171bc8bfb07f850dd71f7c73faa3 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 28 Feb 2023 12:45:05 +0100 Subject: [PATCH 15/26] fix: Added property fare in types. Was wrongly removed while refactoring. --- src/common/types.ts | 1 + src/utils/apiHelper.ts | 1 + src/utils/fixtures.ts | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/src/common/types.ts b/src/common/types.ts index 537e01b..df3eea2 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -11,6 +11,7 @@ export type Itinerary = { }; distance: number; duration: { departure: number; return: number; total: number }; + fare: { adults: number; children: number; infants: number }; price: number; route: Route[]; deep_link: URL; diff --git a/src/utils/apiHelper.ts b/src/utils/apiHelper.ts index f52b001..db82faa 100644 --- a/src/utils/apiHelper.ts +++ b/src/utils/apiHelper.ts @@ -131,6 +131,7 @@ const convertKiwiItineraryToItinerary = (input: KiwiItinerary): Itinerary => { countryTo: input.countryTo, distance: input.distance, duration: input.duration, + fare: input.fare, price: input.price, deep_link: input.deep_link, local_arrival: input.local_arrival, diff --git a/src/utils/fixtures.ts b/src/utils/fixtures.ts index 265fb63..8fdf6a2 100644 --- a/src/utils/fixtures.ts +++ b/src/utils/fixtures.ts @@ -106,6 +106,11 @@ const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ return: 0, total: 4800, }, + fare: { + adults: 60, + children: 60, + infants: 78.41, + }, price: 48, route: [ { From d94969b4af50ecb3307ac4f5104603034e7cab8c Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Wed, 1 Mar 2023 11:46:07 +0100 Subject: [PATCH 16/26] refactor(service) : adding types in User model for mongoose --- package-lock.json | 64 +++++++++++++++++++ package.json | 2 + src/user/authController.ts | 2 +- src/user/userModel.ts | 127 +++++++++++++++++++++++-------------- 4 files changed, 147 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7ac6c51..b060e6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,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/jest": "^29.4.0", @@ -42,6 +43,7 @@ "@types/lodash.groupby": "^4.6.7", "@types/luxon": "^3.2.0", "@types/node": "^18.13.0", + "@types/supertest": "^2.0.12", "@types/validator": "^13.7.12", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", @@ -1369,6 +1371,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", @@ -1388,6 +1396,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", @@ -1532,6 +1546,25 @@ "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/validator": { "version": "13.7.12", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.12.tgz", @@ -8117,6 +8150,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", @@ -8136,6 +8175,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", @@ -8280,6 +8325,25 @@ "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/validator": { "version": "13.7.12", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.12.tgz", diff --git a/package.json b/package.json index 8280b3a..91c453c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,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/jest": "^29.4.0", @@ -49,6 +50,7 @@ "@types/lodash.groupby": "^4.6.7", "@types/luxon": "^3.2.0", "@types/node": "^18.13.0", + "@types/supertest": "^2.0.12", "@types/validator": "^13.7.12", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", diff --git a/src/user/authController.ts b/src/user/authController.ts index 4ba1da7..5651eb3 100644 --- a/src/user/authController.ts +++ b/src/user/authController.ts @@ -152,7 +152,7 @@ const forgotPassword = catchAsync(async (req, res, next) => { // if there has been an error, we reset the password reset token thing user.passwordResetToken = undefined; - user.passwordExpires = undefined; + user.passwordResetExpiresAt = undefined; await user.save({ validateBeforeSave: false }); return next( 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; From b773d1cb5e66b372b6d3887f3a730f4046f03a1f Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Wed, 1 Mar 2023 11:46:18 +0100 Subject: [PATCH 17/26] refactor(test) : adding types in end to end test --- src/tests/endtoend.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tests/endtoend.test.ts b/src/tests/endtoend.test.ts index b2e20e0..34906e2 100644 --- a/src/tests/endtoend.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 @@ -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 @@ -215,7 +219,7 @@ describe('End to end tests', () => { expect(response.statusCode).toBe(200); expect(response.body.totalResults).toBeGreaterThan(0); expect( - response.body.data[0].flights.every((flight) => + response.body.data[0].flights.every((flight: Itinerary) => origins.includes(flight.cityCodeFrom) ) ).toBe(true); From bb4ce9c1b2c0ba6e9f440ed2f5216d5b6b933d18 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Thu, 2 Mar 2023 07:38:07 +0100 Subject: [PATCH 18/26] refactor(service) : adding types to authController --- package-lock.json | 165 ++++----- package.json | 3 +- src/user/authController.integration.test.ts | 60 +++- src/user/authController.ts | 367 +++++++++++--------- 4 files changed, 307 insertions(+), 288 deletions(-) diff --git a/package-lock.json b/package-lock.json index b060e6a..2f76dd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "express-rate-limit": "^6.4.0", "helmet": "^5.0.2", "hpp": "^0.2.3", - "jsonwebtoken": "^8.5.1", + "jsonwebtoken": "^9.0.0", "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", @@ -39,6 +39,7 @@ "@types/express": "^4.17.17", "@types/express-serve-static-core": "^4.17.33", "@types/jest": "^29.4.0", + "@types/jsonwebtoken": "^9.0.1", "@types/lodash": "^4.14.191", "@types/lodash.groupby": "^4.6.7", "@types/luxon": "^3.2.0", @@ -1474,6 +1475,15 @@ "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", @@ -2407,7 +2417,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", @@ -4844,24 +4854,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": { @@ -4869,6 +4873,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", @@ -4964,36 +4982,6 @@ "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" }, - "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.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5006,16 +4994,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" }, @@ -6186,6 +6168,7 @@ "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" } @@ -7091,8 +7074,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", @@ -8253,6 +8235,15 @@ "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", @@ -8924,7 +8915,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", @@ -10727,26 +10718,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" + } } } }, @@ -10830,36 +10823,6 @@ "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==" }, - "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.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.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -10872,16 +10835,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" } @@ -11751,7 +11708,8 @@ "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", @@ -12408,8 +12366,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 91c453c..0dbe1fe 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "express-rate-limit": "^6.4.0", "helmet": "^5.0.2", "hpp": "^0.2.3", - "jsonwebtoken": "^8.5.1", + "jsonwebtoken": "^9.0.0", "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", @@ -46,6 +46,7 @@ "@types/express": "^4.17.17", "@types/express-serve-static-core": "^4.17.33", "@types/jest": "^29.4.0", + "@types/jsonwebtoken": "^9.0.1", "@types/lodash": "^4.14.191", "@types/lodash.groupby": "^4.6.7", "@types/luxon": "^3.2.0", diff --git a/src/user/authController.integration.test.ts b/src/user/authController.integration.test.ts index f02d792..2f3e7ca 100644 --- a/src/user/authController.integration.test.ts +++ b/src/user/authController.integration.test.ts @@ -1,12 +1,12 @@ -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 './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); @@ -25,7 +25,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: Partial, + res: Partial & { + data: { user: HydratedDocument }; + message: string; + }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -40,7 +47,7 @@ describe('AuthController', () => { this.message = obj.message; }), data: null, - message: null, + message: '', 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,10 @@ describe('AuthController', () => { expect.anything() ); - const decoded = await promisify(jwt.verify)( + const decoded = (await jwt.verify( token, process.env.JWT_SECRET - ); + )) as jwt.JwtPayload; expect(decoded.id).toBe(fakeId); }); }); @@ -79,19 +87,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); }); }); }); @@ -143,7 +155,8 @@ describe('AuthController', () => { }); describe('login', () => { - let newUser, fakeUser; + let newUser: HydratedDocument; + let fakeUser: Partial; beforeEach(async () => { // creating a fake user in DB @@ -222,7 +235,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 @@ -240,8 +260,11 @@ describe('AuthController', () => { 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 }, + user: null, }; }); afterEach(async () => { @@ -266,7 +289,8 @@ describe('AuthController', () => { describe('forgotPassword', function () { describe('success cases', () => { - let newUser; + let newUser: HydratedDocument; + beforeEach(async () => { // creating a fake user in DB diff --git a/src/user/authController.ts b/src/user/authController.ts index 5651eb3..5d8fd5e 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,7 +49,8 @@ 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) => { +// FIXME: we could type Request.body (like TypedRequestWithParam) to make sure we have certain query params +const signup = catchAsync(async (req: Request, res: Response) => { // 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, @@ -52,188 +63,214 @@ const signup = catchAsync(async (req, res) => { 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]; - } - - // 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' - 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.passwordResetExpiresAt = 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, From c1d7692b3812250deb28c0d63f53c4db972b366c Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Thu, 2 Mar 2023 10:31:46 +0100 Subject: [PATCH 19/26] refactor(service) : adding types to userController --- src/user/userController.integration.test.ts | 16 +- src/user/userController.ts | 213 +++++++++++--------- 2 files changed, 129 insertions(+), 100 deletions(-) diff --git a/src/user/userController.integration.test.ts b/src/user/userController.integration.test.ts index 51dade4..8642449 100644 --- a/src/user/userController.integration.test.ts +++ b/src/user/userController.integration.test.ts @@ -1,10 +1,13 @@ import userController from './userController'; -import User from './userModel'; -import mongoose from 'mongoose'; +import User, { IUser } from './userModel'; +import mongoose, { HydratedDocument, Types } 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 () => { const DB = process.env.DATABASE.replace( @@ -20,7 +23,9 @@ describe('UserController', () => { mongoose.disconnect(); }); - let req, res, next; + let req: Partial & { user: Partial> }, + res: Partial & { data: any; message: string }, + next: NextFunction; beforeEach(() => { res = { status: jest.fn().mockImplementation(function () { @@ -46,7 +51,7 @@ describe('UserController', () => { test('should get all users', async () => { // console.log(allUsers); - await userController.getAllUsers(req, res); + await userController.getAllUsers(req as Request, res as Response); console.log(res.data.users); expect(res.status).toHaveBeenCalledWith(200); @@ -58,7 +63,6 @@ describe('UserController', () => { }); describe('updateMe', () => { - let newUser, fakeUser; beforeEach(async () => { // creating a fake user in DB @@ -104,7 +108,6 @@ describe('UserController', () => { }); describe('airports', () => { - let newUser, fakeUser; beforeEach(async () => { // creating a fake user in DB @@ -207,7 +210,6 @@ describe('UserController', () => { }); describe('deleteMe', () => { - let newUser, fakeUser; beforeEach(async () => { // creating a fake user in DB diff --git a/src/user/userController.ts b/src/user/userController.ts index b9bdcd9..29e3c20 100644 --- a/src/user/userController.ts +++ b/src/user/userController.ts @@ -3,13 +3,16 @@ 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 getAllUsers = async (req: Request, res: Response) => { const users = await UserRepository.all(); res.status(200).json({ @@ -24,120 +27,144 @@ 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']; + + // 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 UserRepository.updateOne( + req.user.id, + filteredBody ); - } - - 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 - // FIXME: ça c'est une business rule - const filteredBody = utils.filterObj(req.body, allowedFields); - // 3) Update user - // FIXME: le controller est directement couplé à MongoDB ... faudrait un service userService avec user.findMe, user.updateMe, .... - // est-ce que pour séparer en couche on aurait pas mieux fait de mettre tous mes models ensemble au lieu de mettre par métier? - 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 UserRepository.deleteOne(req.user.id); - - 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 UserRepository.findOne(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 addFavAirportToUser = 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 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 + ) + ); + } + + const updatedUser = await UserRepository.addFavAirportToUser( + req.user.id, + req.body.airport ); - } - const updatedUser = await UserRepository.addFavAirportToUser( - req.user.id, - req.body.airport - ); - - res.status(200).json({ - status: 'success', - data: { - favAirports: updatedUser.favAirports, - }, - }); -}); + 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 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 + ) + ); + } + + const updatedUser = await UserRepository.removeFavAirportFromUser( + req.user.id, + req.body.airport ); - } - - const updatedUser = await UserRepository.removeFavAirportFromUser( - req.user.id, - req.body.airport - ); - res.status(200).json({ - status: 'success', - data: { - favAirports: updatedUser.favAirports, - }, - }); -}); + res.status(200).json({ + status: 'success', + data: { + favAirports: updatedUser.favAirports, + }, + }); + } +); export = { addFavAirport: addFavAirportToUser, From 0c804e8108f51ff9a6cd52a057b4e5fdbe3c8109 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Thu, 2 Mar 2023 10:50:21 +0100 Subject: [PATCH 20/26] fix: compilation issues in authController and userController --- src/user/authController.integration.test.ts | 29 ++++++++++++--------- src/user/userController.integration.test.ts | 10 ++++--- src/user/userController.ts | 4 +-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/user/authController.integration.test.ts b/src/user/authController.integration.test.ts index 2f3e7ca..adbdc29 100644 --- a/src/user/authController.integration.test.ts +++ b/src/user/authController.integration.test.ts @@ -12,6 +12,8 @@ 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 @@ -29,8 +31,8 @@ describe('AuthController', () => { // TODO: improve typing of req and have something like TypedRequestQueryWithFilter let req: Partial, res: Partial & { - data: { user: HydratedDocument }; - message: string; + data?: { user: HydratedDocument }; + message?: string; }, next: NextFunction; beforeEach(() => { @@ -46,8 +48,6 @@ describe('AuthController', () => { this.data = obj.data; this.message = obj.message; }), - data: null, - message: '', cookie: jest.fn().mockImplementation(function () { // console.log('calling res.status'); return this; @@ -74,6 +74,8 @@ describe('AuthController', () => { expect.anything() ); + if (!process.env.JWT_SECRET) throw Error('missing env variables'); + const decoded = (await jwt.verify( token, process.env.JWT_SECRET @@ -103,7 +105,7 @@ describe('AuthController', () => { expect(res.cookie).toHaveBeenCalled(); expect(fakeUserFromDb.password).toBeUndefined(); expect(res.status).toHaveBeenCalledWith(fakeStatusCode); - expect(res.data.user._id).toBe(fakeUserFromDb._id); + expect(res.data?.user._id).toBe(fakeUserFromDb._id); }); }); }); @@ -133,12 +135,14 @@ describe('AuthController', () => { // 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 @@ -188,7 +192,7 @@ describe('AuthController', () => { await authController.login(req, res, 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', () => { @@ -241,7 +245,7 @@ describe('AuthController', () => { // 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 }; + let req: Partial & { user?: HydratedDocument }; beforeEach(async () => { // creating a fake user in DB @@ -256,6 +260,8 @@ 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, }); @@ -264,7 +270,6 @@ describe('AuthController', () => { // otherwise it errors on req.user.id saying property user does not exist on Request req = { headers: { authorization: 'Bearer ' + token }, - user: null, }; }); afterEach(async () => { @@ -274,7 +279,7 @@ describe('AuthController', () => { test('should grant access to protected route', async function () { await authController.protect(req, res, next); - expect(req.user.id).toEqual(newUser.id); + expect(req.user?.id).toEqual(newUser.id); }); }); describe('error cases', () => { @@ -325,7 +330,7 @@ describe('AuthController', () => { 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(); diff --git a/src/user/userController.integration.test.ts b/src/user/userController.integration.test.ts index 8642449..6ea3971 100644 --- a/src/user/userController.integration.test.ts +++ b/src/user/userController.integration.test.ts @@ -1,6 +1,6 @@ import userController from './userController'; import User, { IUser } from './userModel'; -import mongoose, { HydratedDocument, Types } from 'mongoose'; +import mongoose, { HydratedDocument } from 'mongoose'; import { faker } from '@faker-js/faker'; import { NextFunction, Request, Response } from 'express-serve-static-core'; @@ -10,6 +10,8 @@ describe('UserController', () => { 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 @@ -24,7 +26,7 @@ describe('UserController', () => { }); let req: Partial & { user: Partial> }, - res: Partial & { data: any; message: string }, + res: Partial & Partial<{ data: any; message: string }>, next: NextFunction; beforeEach(() => { res = { @@ -37,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) { diff --git a/src/user/userController.ts b/src/user/userController.ts index 29e3c20..079c24e 100644 --- a/src/user/userController.ts +++ b/src/user/userController.ts @@ -117,10 +117,10 @@ const addFavAirportToUser = catchAsync( ); } - const updatedUser = await UserRepository.addFavAirportToUser( + const updatedUser = (await UserRepository.addFavAirportToUser( req.user.id, req.body.airport - ); + )) as IUser; res.status(200).json({ status: 'success', From c1f94e73ff9184323ef5cfc773b4479f95f39fae Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 21 Mar 2023 12:08:44 +0100 Subject: [PATCH 21/26] refactor(service) : adding types to userRepository --- package-lock.json | 1 - src/user/userRepository.ts | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f76dd0..cdfabc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "pulpito", "version": "2.1.0", - "hasInstallScript": true, "license": "ISC", "dependencies": { "axios": "^0.26.0", diff --git a/src/user/userRepository.ts b/src/user/userRepository.ts index 1ad067b..8d6af73 100644 --- a/src/user/userRepository.ts +++ b/src/user/userRepository.ts @@ -1,7 +1,8 @@ -import User from './userModel'; +import { IataCode } from '../common/types'; +import User, { IUser } from './userModel'; export class UserRepository { - static updateOne = async (id, update) => { + 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() @@ -12,17 +13,17 @@ export class UserRepository { return await User.find(); }; - static findOne = async (id) => { + static findOne = async (id: string) => { return await User.findById(id); }; - static deleteOne = async (id) => { + static deleteOne = async (id: string) => { await User.findByIdAndUpdate(id, { active: false, }); }; - static addFavAirportToUser = async (id, airport) => { + static addFavAirportToUser = async (id: string, airport: IataCode) => { return await User.findByIdAndUpdate( id, { @@ -34,7 +35,7 @@ export class UserRepository { ); }; - static removeFavAirportFromUser = async (id, airport) => { + static removeFavAirportFromUser = async (id: string, airport: IataCode) => { return await User.findByIdAndUpdate( id, { From 7efd52d5a307713499dfbd813c9d515d2634b8f8 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Tue, 21 Mar 2023 12:09:10 +0100 Subject: [PATCH 22/26] fix: compilation issues in test and fixtures files --- .../destinationsController.integration.test.ts | 12 ++++++------ src/utils/apiHelper.unit.test.ts | 12 ++++++------ src/utils/fixtures.ts | 16 +++++++++++++--- src/utils/validator.unit.test.ts | 2 +- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/destinations/destinationsController.integration.test.ts b/src/destinations/destinationsController.integration.test.ts index c711583..06852c2 100644 --- a/src/destinations/destinationsController.integration.test.ts +++ b/src/destinations/destinationsController.integration.test.ts @@ -175,17 +175,17 @@ describe('Destinations Controller', function () { 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', }) ); }); diff --git a/src/utils/apiHelper.unit.test.ts b/src/utils/apiHelper.unit.test.ts index f8a2fa0..ef6290a 100644 --- a/src/utils/apiHelper.unit.test.ts +++ b/src/utils/apiHelper.unit.test.ts @@ -344,9 +344,9 @@ describe('API Helper', function () { 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', () => { @@ -360,9 +360,9 @@ describe('API Helper', function () { 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/fixtures.ts b/src/utils/fixtures.ts index 8fdf6a2..7020619 100644 --- a/src/utils/fixtures.ts +++ b/src/utils/fixtures.ts @@ -107,9 +107,9 @@ const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ total: 4800, }, fare: { - adults: 60, - children: 60, - infants: 78.41, + adults: 48, + children: 48, + infants: 48, }, price: 48, route: [ @@ -152,6 +152,11 @@ const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ return: 0, total: 4500, }, + fare: { + adults: 49, + children: 49, + infants: 49, + }, price: 49, route: [ { @@ -192,6 +197,11 @@ const CHEAPEST_DESTINATION_KIWI_RESULT_FIXTURE = [ return: 0, total: 4200, }, + fare: { + adults: 52, + children: 52, + infants: 52, + }, price: 52, route: [ { diff --git a/src/utils/validator.unit.test.ts b/src/utils/validator.unit.test.ts index 9b526e5..65e0fd2 100644 --- a/src/utils/validator.unit.test.ts +++ b/src/utils/validator.unit.test.ts @@ -1,5 +1,5 @@ import validator from './validator'; -import { isAlpha, isDate, isNumeric } from 'validator'; +import { isAlpha, isDate, isNumeric } from 'validator/validator'; describe('validator utils', () => { describe('isCommaSeparatedAlpha', () => { From e5ad7c3099afe0238d93318b25611be9fae0bdcb Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Wed, 22 Mar 2023 10:47:47 +0100 Subject: [PATCH 23/26] refactor(service) : partially added types to apiHelper before big refactor --- src/common/types.ts | 2 +- src/utils/apiHelper.ts | 56 ++++++++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/common/types.ts b/src/common/types.ts index df3eea2..5fa98da 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -21,7 +21,7 @@ export type Itinerary = { utc_departure: ISODate; }; -export type CommonDestination = { +export type DestinationWithItineraries = { cityTo: string; flights: Itinerary[]; countryTo: string; diff --git a/src/utils/apiHelper.ts b/src/utils/apiHelper.ts index db82faa..6a0ea40 100644 --- a/src/utils/apiHelper.ts +++ b/src/utils/apiHelper.ts @@ -2,7 +2,8 @@ import { Settings, Duration, DateTime } from 'luxon'; // import groupByToMap from 'core-js-pure/actual/array/group-by-to-map'; import groupBy from 'lodash.groupby'; import { - CommonDestination, + DestinationWithItineraries, + IataCode, Itinerary, KiwiItinerary, KiwiRoute, @@ -35,7 +36,10 @@ const groupByDestination = ( * @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) ); @@ -47,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)') +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)') // 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 return origins.every( (origin) => - destination.findIndex( - (value) => value.cityCodeFrom === origin || value.flyFrom === origin + itineraries.findIndex((itinerary) => + itineraryHasOrigin(itinerary, origin) ) > -1 ); }; +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' @@ -68,13 +76,13 @@ const isCommonDestination = (destination, origins) => { * @returns an object for that destination, with aggregated info */ const prepareItineraryData = ( - dest, + dest: string, itineraries: Itinerary[], - passengersPerOrigin + passengersPerOrigin: Map ) => { - // 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: Partial = { cityTo: dest }; + const itinerary: 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 @@ -164,7 +172,7 @@ const convertKiwiRouteToRoute = (input: KiwiRoute): Route => { * @param {*} input itinerary to be cleaned. Won't be mutated. * @returns a copy of the itinerary, but cleaned. */ -const cleanItineraryData = (input: Itinerary) => { +const cleanItineraryData = (input: Itinerary): Itinerary => { const itinerary = Object.assign({}, input); // delete itinerary.type_flights; @@ -202,7 +210,13 @@ const cleanItineraryData = (input: Itinerary) => { // 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 @@ -211,19 +225,19 @@ const cleanItineraryData = (input: Itinerary) => { 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"), }, }; // 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 @@ -232,12 +246,6 @@ const cleanItineraryData = (input: Itinerary) => { 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" - ), }; } From 26e6f73bdbb1374290a2a1a3ebcc110dcfb353bd Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Thu, 23 Mar 2023 12:41:59 +0100 Subject: [PATCH 24/26] refactor(service) : added types to apiHelper, resultsHelper and refactored Itinerary, destinations, Route.... --- package-lock.json | 31 ++ package.json | 2 + src/common/types.ts | 114 ++++- src/data/flightService.integration.test.ts | 4 +- src/data/flightService.ts | 55 +-- ...destinationsController.integration.test.ts | 50 ++- src/destinations/destinationsController.ts | 27 +- src/destinations/destinationsService.ts | 10 +- src/tests/endtoend.test.ts | 4 +- src/utils/apiHelper.ts | 394 ++++++++++-------- src/utils/apiHelper.unit.test.ts | 72 ++-- src/utils/fixtures.ts | 47 +-- src/utils/resultsHelper.ts | 160 ++++--- src/utils/validator.unit.test.ts | 5 +- src/views/common.pug | 78 ++-- src/views/viewController.ts | 1 + 16 files changed, 613 insertions(+), 441 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdfabc1..5487f58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "pulpito", "version": "2.1.0", + "hasInstallScript": true, "license": "ISC", "dependencies": { "axios": "^0.26.0", @@ -21,6 +22,7 @@ "helmet": "^5.0.2", "hpp": "^0.2.3", "jsonwebtoken": "^9.0.0", + "lodash.clonedeep": "^4.5.0", "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", @@ -40,6 +42,7 @@ "@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/node": "^18.13.0", @@ -1489,6 +1492,15 @@ "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", @@ -4976,6 +4988,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "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.groupby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", @@ -8249,6 +8266,15 @@ "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", @@ -10817,6 +10843,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "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.groupby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", diff --git a/package.json b/package.json index 0dbe1fe..0cb0885 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "helmet": "^5.0.2", "hpp": "^0.2.3", "jsonwebtoken": "^9.0.0", + "lodash.clonedeep": "^4.5.0", "lodash.groupby": "^4.6.0", "luxon": "^2.4.0", "mongoose": "^6.2.2", @@ -48,6 +49,7 @@ "@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/node": "^18.13.0", diff --git a/src/common/types.ts b/src/common/types.ts index 5fa98da..5750d22 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,3 +1,8 @@ +/** + * Itinerary represents a full travel, with : + * - its outbound (oneway) and inbound (return) routes + * - its connections + */ export type Itinerary = { flyFrom: IataCode; flyTo: IataCode; @@ -13,26 +18,52 @@ export type Itinerary = { duration: { departure: number; return: number; total: number }; fare: { adults: number; children: number; infants: number }; price: number; - route: Route[]; + // 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; + // 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; - flights: Itinerary[]; + itineraries: Itinerary[]; countryTo: string; cityCodeTo: string; price: number; distance: number; - totalDurationDepartureInMinutes: number; - totalDurationReturnInMinutes: 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; @@ -46,9 +77,28 @@ export type Route = { utc_departure: ISODate; }; -// FIXME: KiwiRoute should be used in Kiwi Itinerary ... -export type KiwiRoute = Route; -export type KiwiItinerary = Itinerary & { route: KiwiRoute[] }; +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" @@ -112,3 +162,45 @@ export type BaseParamModel = { 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/data/flightService.integration.test.ts b/src/data/flightService.integration.test.ts index 65c54df..f29903e 100644 --- a/src/data/flightService.integration.test.ts +++ b/src/data/flightService.integration.test.ts @@ -55,7 +55,7 @@ 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: WeekendLengthEnum.LONG, @@ -76,7 +76,7 @@ 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: WeekendLengthEnum.SHORT, diff --git a/src/data/flightService.ts b/src/data/flightService.ts index 5084641..b6f1309 100644 --- a/src/data/flightService.ts +++ b/src/data/flightService.ts @@ -2,9 +2,11 @@ import axios from 'axios'; import helper from '../utils/apiHelper'; import { setupCache } from 'axios-cache-interceptor'; import { - DateDDMMYYYY, - IataCode, + DayOfWeek, Itinerary, + KiwiAPIAllDaysParams, + KiwiAPIWeekendParams, + KiwiBaseAPIParams, KiwiItinerary, RegularFlightsParams, WeekendFlightsParams, @@ -37,48 +39,6 @@ const DEFAULT_ADULTS_PARAM = 1; const DEFAULT_CHILDREN_PARAM = 0; const DEFAULT_INFANTS_PARAM = 0; -enum DayOfWeek { - SUNDAY = 0, - MONDAY = 1, - TUESDAY = 2, - WEDNESDAY = 3, - THURSDAY = 4, - FRIDAY = 5, - SATURDAY = 6, -} - -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'; -}; - -type KiwiAPIWeekendParams = { - fly_to: IataCode; - - fly_days?: DayOfWeek[]; - ret_fly_days?: DayOfWeek[]; - nights_in_dst_from?: number; - nights_in_dst_to?: number; -} & KiwiBaseAPIParams; - -type KiwiAPIAllDaysParams = { - fly_to: 'anywhere'; - returnFrom?: DateDDMMYYYY; - returnTo?: DateDDMMYYYY; - ret_from_diff_airport?: number; - ret_to_diff_airport?: number; - one_for_city?: number; -} & KiwiBaseAPIParams; - setupCache(axios, { ttl: 1000 * 60 * 15 }); //15 minutes // FIXME: better handle errors @@ -177,7 +137,8 @@ const getWeekendFlights = async ( 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.prepareAxiosParams(axiosParams); + const preparedAxiosParams = + helper.prepareWeekendParamsForAxios(axiosParams); const response = await axios.get( `${process.env.KIWI_URL}?${preparedAxiosParams.toString()}`, { @@ -186,8 +147,10 @@ const getWeekendFlights = async ( }, } ); + if (response && response.data) { - return response.data.data; + const kiwiItineraries: KiwiItinerary[] = response.data.data; + return kiwiItineraries.map(helper.convertKiwiItineraryToItinerary); } else { return []; } diff --git a/src/destinations/destinationsController.integration.test.ts b/src/destinations/destinationsController.integration.test.ts index 06852c2..5e90506 100644 --- a/src/destinations/destinationsController.integration.test.ts +++ b/src/destinations/destinationsController.integration.test.ts @@ -25,6 +25,8 @@ import { 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 () { @@ -37,7 +39,11 @@ describe('Destinations Controller', function () { 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 }; @@ -70,9 +76,9 @@ describe('Destinations Controller', function () { expect(flightService.getFlights).toHaveBeenCalledWith( expect.objectContaining({ - adults: 1, - children: 0, - infants: 0, + adults: '1', + children: '0', + infants: '0', }) ); }); @@ -139,10 +145,26 @@ describe('Destinations Controller', function () { 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 }; @@ -256,7 +278,11 @@ describe('Destinations Controller', function () { 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 }; @@ -318,9 +344,9 @@ describe('Destinations Controller', function () { expect(flightService.getWeekendFlights).toHaveBeenCalledWith( expect.objectContaining({ - adults: 1, - children: 0, - infants: 0, + adults: '1', + children: '0', + infants: '0', }) ); }); diff --git a/src/destinations/destinationsController.ts b/src/destinations/destinationsController.ts index 28291cf..df19e72 100644 --- a/src/destinations/destinationsController.ts +++ b/src/destinations/destinationsController.ts @@ -25,11 +25,12 @@ const getCheapestDestinations = catchAsyncKiwi( req: TypedRequestQueryWithFilter, res: APISuccessResponse ): Promise => { - const params = helper.prepareDefaultAPIParams(req.query); + 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); @@ -65,20 +66,17 @@ const getCommonDestinations = catchAsyncKiwi( const origins = req.query.origin.split(','); - let commonItineraries = await destinationsService.buildCommonItineraries( + let destinations = await destinationsService.buildCommonItineraries( allOriginsParams, origins ); - const totalResults = commonItineraries.length; - commonItineraries = resultsHelper.applyFilters( - commonItineraries, - req.filter - ); + const totalResults = destinations.length; + destinations = resultsHelper.applyFilters(destinations, req.filter); res.status(200).json({ status: 'success', totalResults, - shownResults: commonItineraries.length, - data: commonItineraries, + shownResults: destinations.length, + data: destinations, }); } ); @@ -88,11 +86,12 @@ const getCheapestWeekend = catchAsyncKiwi( req: TypedRequestQueryWithFilter, res: APISuccessResponse ): Promise => { - const params = helper.prepareDefaultAPIParams(req.query); + 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; itineraries = resultsHelper.applyFilters(itineraries, req.filter); diff --git a/src/destinations/destinationsService.ts b/src/destinations/destinationsService.ts index c6ddd47..2bf305e 100644 --- a/src/destinations/destinationsService.ts +++ b/src/destinations/destinationsService.ts @@ -44,15 +44,15 @@ const buildCommonItineraries = async ( 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/tests/endtoend.test.ts b/src/tests/endtoend.test.ts index 34906e2..a6c3e70 100644 --- a/src/tests/endtoend.test.ts +++ b/src/tests/endtoend.test.ts @@ -219,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: Itinerary) => - origins.includes(flight.cityCodeFrom) + response.body.data[0].itineraries.every((itinerary: Itinerary) => + origins.includes(itinerary.cityCodeFrom) ) ).toBe(true); }); diff --git a/src/utils/apiHelper.ts b/src/utils/apiHelper.ts index 6a0ea40..4baf231 100644 --- a/src/utils/apiHelper.ts +++ b/src/utils/apiHelper.ts @@ -8,7 +8,7 @@ import { KiwiItinerary, KiwiRoute, RegularFlightsParams, - Route, + WeekendFlightsParams, } from '../common/types'; Settings.defaultLocale = 'fr'; @@ -53,8 +53,6 @@ const filterDestinationCities = ( */ 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)') - // 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 return origins.every( (origin) => @@ -64,6 +62,8 @@ const isCommonDestination = (itineraries: Itinerary[], origins: IataCode[]) => { ); }; +// 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; }; @@ -75,210 +75,251 @@ const itineraryHasOrigin = (itinerary: Itinerary, origin: IataCode) => { * @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 = ( +const prepareDestinationData = ( dest: string, itineraries: Itinerary[], passengersPerOrigin: Map -) => { - const itinerary: Partial = { +): 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; -}; - -const convertKiwiItineraryToItinerary = (input: KiwiItinerary): Itinerary => { - return { - flyFrom: input.flyFrom, - flyTo: input.flyTo, - cityFrom: input.cityFrom, - cityCodeFrom: input.cityCodeFrom, - cityTo: input.cityTo, - cityCodeTo: input.cityCodeTo, - countryTo: input.countryTo, - distance: input.distance, - duration: input.duration, - fare: input.fare, - price: input.price, - deep_link: input.deep_link, - local_arrival: input.local_arrival, - utc_arrival: input.utc_arrival, - local_departure: input.local_departure, - utc_departure: input.utc_departure, - route: input.route.map(convertKiwiRouteToRoute), - }; + // 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; }; -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, +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), }; -}; -/** - * 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); + 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: { - 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 - ), - }, + 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 = { - 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), + 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 + 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; }; @@ -287,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( @@ -302,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) { @@ -323,12 +366,14 @@ const prepareAxiosParams = (params) => { * @returns */ // 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) => { +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', }; }; @@ -350,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 = { @@ -358,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', }; }); @@ -427,13 +476,12 @@ const getMapPassengersPerOrigin = ( }; export = { - cleanItineraryData, convertKiwiItineraryToItinerary, extractConnections, filterDestinationCities, isCommonDestination, - prepareItineraryData, - prepareAxiosParams, + prepareDestinationData, + prepareWeekendParamsForAxios, prepareDefaultAPIParams, prepareSeveralOriginAPIParams, prepareSeveralOriginAPIParamsFromView, diff --git a/src/utils/apiHelper.unit.test.ts b/src/utils/apiHelper.unit.test.ts index ef6290a..e60df0b 100644 --- a/src/utils/apiHelper.unit.test.ts +++ b/src/utils/apiHelper.unit.test.ts @@ -4,6 +4,7 @@ import apiReturnAnswer from '../datasets/fixtures/apiReturnAnswer.json'; import { Itinerary, KiwiItinerary, + KiwiRoute, RegularFlightsParams, } from '../common/types'; import { @@ -26,20 +27,19 @@ describe('API Helper', function () { const cleaned = helper.convertKiwiItineraryToItinerary(itinerary); expect(cleaned).not.toHaveProperty('countryFrom'); }); - }); - describe('cleanItineraryData', function () { - 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'); }); }); @@ -49,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'); }); @@ -59,25 +59,28 @@ 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 = [ + 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 @@ -85,11 +88,11 @@ describe('API Helper', function () { 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 @@ -149,11 +152,14 @@ describe('API Helper', function () { // {cityTo:'Ibiza',cityFrom:'Madrid'}, // {cityTo:'Prague',cityFrom:'Bordeaux'} // ]; - const itineraries = [ + 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 = [ @@ -232,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']; @@ -255,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); @@ -268,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'); }); }); @@ -298,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', () => { @@ -316,9 +322,9 @@ 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'); }); }); diff --git a/src/utils/fixtures.ts b/src/utils/fixtures.ts index 7020619..2a93146 100644 --- a/src/utils/fixtures.ts +++ b/src/utils/fixtures.ts @@ -282,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'], @@ -363,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', @@ -450,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', @@ -480,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', @@ -499,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', @@ -586,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', @@ -616,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', @@ -635,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', @@ -697,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'], @@ -729,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', @@ -759,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', @@ -789,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', @@ -819,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', @@ -838,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', @@ -929,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', @@ -959,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', @@ -989,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', @@ -1019,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', @@ -1038,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', @@ -1125,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', @@ -1155,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', @@ -1185,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', @@ -1204,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', @@ -1298,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', @@ -1328,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', @@ -1358,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', @@ -1388,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', @@ -1407,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', @@ -1606,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', @@ -1697,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', @@ -1727,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', @@ -1746,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', @@ -1841,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', @@ -1871,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', @@ -1890,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', @@ -1981,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', @@ -2011,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', @@ -2030,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', @@ -2121,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', @@ -2151,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', @@ -2170,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..581f602 100644 --- a/src/utils/resultsHelper.ts +++ b/src/utils/resultsHelper.ts @@ -1,30 +1,44 @@ +import { + DestinationWithItineraries, + FilterParams, + Itinerary, +} from '../common/types'; +import cloneDeep from 'lodash.clonedeep'; + import { RESULTS_SEARCH_LIMIT, DEFAULT_SORT_FIELD } from '../config'; /** - * 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: DestinationWithItineraries | Itinerary, + 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 +49,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: DestinationWithItineraries | Itinerary, + 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 +85,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,33 +128,41 @@ 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: DestinationWithItineraries[] | Itinerary[], + 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) => { - const filtered = filterByMaxConnections( - itinerary, - filterParams.maxConnections - ); - return filtered; - }); + if (filterParams.maxConnections !== undefined) { + result = result.filter( + (itinerary: DestinationWithItineraries | Itinerary) => { + const filtered = filterByMaxConnections( + itinerary, + filterParams.maxConnections + ); + return filtered; + } + ); } // filter by price range if (filterParams.priceFrom || filterParams.priceTo) { - result = result.filter((itinerary) => { - const filtered = filterByPriceRange( - itinerary, - filterParams.priceFrom, - filterParams.priceTo - ); - return filtered; - }); + result = result.filter( + (itinerary: DestinationWithItineraries | Itinerary) => { + const filtered = filterByPriceRange( + itinerary, + filterParams.priceFrom, + filterParams.priceTo + ); + return filtered; + } + ); } - return result; + return result as DestinationWithItineraries[] | Itinerary[]; }; /** @@ -140,12 +171,13 @@ 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: DestinationWithItineraries[] | Itinerary[], + 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 +186,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: DestinationWithItineraries[] | Itinerary[], + 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); }; /** @@ -244,6 +281,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/validator.unit.test.ts b/src/utils/validator.unit.test.ts index 65e0fd2..4150cd7 100644 --- a/src/utils/validator.unit.test.ts +++ b/src/utils/validator.unit.test.ts @@ -1,5 +1,6 @@ import validator from './validator'; import { isAlpha, isDate, isNumeric } from 'validator/validator'; +import { ParamModel } from '../common/types'; describe('validator utils', () => { describe('isCommaSeparatedAlpha', () => { @@ -68,7 +69,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 = { @@ -108,7 +109,7 @@ describe('validator utils', () => { name: 'dateParam', typeCheck: (str) => isDate(str, { format: 'DD/MM/YYYY' }), }, - ]; + ] as ParamModel[]; test('should return an empty array if parameters have the correct type', () => { const params = { 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..22c0058 100644 --- a/src/views/viewController.ts +++ b/src/views/viewController.ts @@ -47,6 +47,7 @@ const searchFlights = catchAsyncKiwi(async (req, res) => { requestParams ); + // FIXME: once we migrated viewController to TS, we need to update prepareSeveralOriginAPIParamsFromView const allOriginParams = helper.prepareSeveralOriginAPIParamsFromView(requestParams); From 7b62d1ded2a317f43fc12130f2f5528c09ea77d7 Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Thu, 23 Mar 2023 12:57:29 +0100 Subject: [PATCH 25/26] fix: compilation issues in resultsHelper.unit.test file --- src/utils/resultsHelper.unit.test.ts | 230 +++++++++++++++++---------- 1 file changed, 149 insertions(+), 81 deletions(-) diff --git a/src/utils/resultsHelper.unit.test.ts b/src/utils/resultsHelper.unit.test.ts index 6572417..52b2afe 100644 --- a/src/utils/resultsHelper.unit.test.ts +++ b/src/utils/resultsHelper.unit.test.ts @@ -1,165 +1,203 @@ import helper from './resultsHelper'; import { RESULTS_SEARCH_LIMIT } from '../config'; +import { + DestinationWithItineraries, + FilterParams, + Itinerary, + Route, +} from '../common/types'; describe('Results Helper', () => { const ZERO_CONNECTIONS = []; 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 +229,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 +280,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 +305,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 +316,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 +347,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); }); From b80fd081228a272e3b9cb2a59e5f97da4920700f Mon Sep 17 00:00:00 2001 From: nicdo77 Date: Fri, 24 Mar 2023 13:34:19 +0100 Subject: [PATCH 26/26] refactor : added types to app.ts, and fixed many small bugs --- package-lock.json | 108 ++++++++++++++- package.json | 12 +- src/app.ts | 14 +- ...destinationsController.integration.test.ts | 120 +++++++++++++---- src/user/authController.integration.test.ts | 32 ++--- src/user/authController.ts | 27 ++-- src/user/userController.integration.test.ts | 24 ++-- src/utils/appError.ts | 4 +- src/utils/catchAsync.ts | 26 ++-- src/utils/email.ts | 21 ++- src/utils/resultsHelper.ts | 82 ++++++------ src/utils/resultsHelper.unit.test.ts | 25 ++-- src/utils/utils.ts | 16 +-- src/utils/validator.unit.test.ts | 66 ++++----- src/utils/xss.ts | 22 +++ src/views/viewController.ts | 126 ++++++++++-------- tsconfig.json | 6 +- 17 files changed, 477 insertions(+), 254 deletions(-) create mode 100644 src/utils/xss.ts diff --git a/package-lock.json b/package-lock.json index 5487f58..9be3c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,22 +32,28 @@ "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", @@ -1437,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", @@ -1522,11 +1537,29 @@ "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", @@ -1586,6 +1619,12 @@ "@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", @@ -1606,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", @@ -7073,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", @@ -8211,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", @@ -8296,11 +8355,29 @@ "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", @@ -8360,6 +8437,12 @@ "@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", @@ -8380,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", @@ -12380,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", diff --git a/package.json b/package.json index 0cb0885..3405918 100644 --- a/package.json +++ b/package.json @@ -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" @@ -39,22 +39,28 @@ "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/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/destinations/destinationsController.integration.test.ts b/src/destinations/destinationsController.integration.test.ts index 5e90506..241d6a6 100644 --- a/src/destinations/destinationsController.integration.test.ts +++ b/src/destinations/destinationsController.integration.test.ts @@ -14,7 +14,7 @@ import { import flightService from '../data/flightService'; import AppError from '../utils/appError'; -import { Request, NextFunction } from 'express-serve-static-core'; +import { Request, NextFunction, Response } from 'express-serve-static-core'; import { APISuccessResponse, TypedRequestQueryWithFilter, @@ -31,7 +31,7 @@ import helper from '../utils/apiHelper'; describe('Destinations Controller', function () { describe('getCheapestDestinations', function () { describe('success cases', function () { - let req: Partial>, + 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 @@ -45,7 +45,9 @@ describe('Destinations Controller', function () { ) ); - req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE }; + req = { + query: CHEAPEST_DESTINATION_QUERY_FIXTURE, + } as TypedRequestQueryWithFilter; res = { status: jest.fn().mockImplementation(function (code) { @@ -65,14 +67,22 @@ 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({ @@ -84,7 +94,11 @@ describe('Destinations Controller', function () { }); 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); @@ -101,7 +115,7 @@ describe('Destinations Controller', function () { }); }); describe('error cases', function () { - let req: Partial>, + let req: TypedRequestQueryWithFilter, res: Partial & { data: Itinerary[] }, next: NextFunction; beforeEach(() => { @@ -118,9 +132,15 @@ describe('Destinations Controller', function () { }); test('should return error 400 when unknown origin like PXR', async function () { - req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN }; - - await destinationsController.getCheapestDestinations(req, res, next); + req = { + query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, + } as TypedRequestQueryWithFilter; + + await destinationsController.getCheapestDestinations( + req, + res as Response, + next + ); // check that response is an error expect(next).toHaveBeenCalledWith(expect.any(AppError)); @@ -137,7 +157,7 @@ describe('Destinations Controller', function () { describe('getCommonDestinations', function () { describe('success cases', function () { - let req: Partial>, + let req: TypedRequestQueryWithFilter, res: Partial & { data: Itinerary[] }, next: NextFunction; @@ -166,7 +186,9 @@ describe('Destinations Controller', function () { ) ); - req = { query: COMMON_DESTINATION_QUERY_FIXTURE }; + req = { + query: COMMON_DESTINATION_QUERY_FIXTURE, + } as TypedRequestQueryWithFilter; res = { status: jest.fn().mockImplementation(function () { @@ -184,7 +206,11 @@ 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 @@ -193,7 +219,11 @@ describe('Destinations Controller', function () { // 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({ @@ -213,7 +243,11 @@ describe('Destinations Controller', function () { }); 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); @@ -229,7 +263,11 @@ describe('Destinations Controller', function () { .fn() .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MAD) .mockResolvedValueOnce(COMMON_DESTINATION_KIWI_RESULT_FIXTURE_MRS); - 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); @@ -255,7 +293,11 @@ describe('Destinations Controller', function () { test('should return error 400 when unknown origin like PXR', async function () { req = { query: COMMON_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN }; - await destinationsController.getCommonDestinations(req, res, next); + await destinationsController.getCommonDestinations( + req as Request, + res as Response, + next + ); expect(next).toHaveBeenCalledWith(expect.any(AppError)); expect(next).toHaveBeenCalledWith( @@ -270,7 +312,7 @@ describe('Destinations Controller', function () { describe('getCheapestWeekend', function () { describe('success cases', function () { - let req: Partial>, + 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 @@ -284,7 +326,9 @@ describe('Destinations Controller', function () { ) ); - req = { query: CHEAPEST_WEEKEND_QUERY_FIXTURE }; + req = { + query: CHEAPEST_WEEKEND_QUERY_FIXTURE, + } as TypedRequestQueryWithFilter; res = { status: jest.fn().mockImplementation(function () { @@ -302,7 +346,11 @@ describe('Destinations Controller', function () { }); 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); @@ -320,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' } }; - - await destinationsController.getCheapestWeekend(req, res, next); + const req = { + query: { origin: 'CDG' }, + } as TypedRequestQueryWithFilter; + + await destinationsController.getCheapestWeekend( + req, + res as Response, + next + ); expect(res.status).toHaveBeenCalledWith(200); expect(Array.isArray(res.data)).toBe(true); @@ -340,7 +394,11 @@ describe('Destinations Controller', function () { // 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, next); + await destinationsController.getCheapestWeekend( + req, + res as Response, + next + ); expect(flightService.getWeekendFlights).toHaveBeenCalledWith( expect.objectContaining({ @@ -352,7 +410,7 @@ describe('Destinations Controller', function () { }); }); describe('error cases', function () { - let req: Partial>, + let req: TypedRequestQueryWithFilter, res: Partial & { data: Itinerary[] }, next: NextFunction; beforeEach(() => { @@ -369,9 +427,15 @@ describe('Destinations Controller', function () { }); test('should return error 400 when unknown origin like PXR', async function () { - req = { query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN }; - - await destinationsController.getCheapestWeekend(req, res, next); + req = { + query: CHEAPEST_DESTINATION_QUERY_FIXTURE_NON_EXISTING_ORIGIN, + } as TypedRequestQueryWithFilter; + + await destinationsController.getCheapestWeekend( + req, + res as Response, + next + ); // check that response is an error expect(next).toHaveBeenCalledWith(expect.any(AppError)); diff --git a/src/user/authController.integration.test.ts b/src/user/authController.integration.test.ts index adbdc29..fecd1c1 100644 --- a/src/user/authController.integration.test.ts +++ b/src/user/authController.integration.test.ts @@ -29,7 +29,7 @@ describe('AuthController', () => { // 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: Partial, + let req: Request, res: Partial & { data?: { user: HydratedDocument }; message?: string; @@ -127,9 +127,9 @@ 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; @@ -187,9 +187,9 @@ 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); @@ -204,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( @@ -277,7 +277,7 @@ 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); }); @@ -315,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 @@ -325,7 +325,7 @@ 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(); @@ -340,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 @@ -348,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(); @@ -367,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); @@ -384,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 5d8fd5e..a949fa1 100644 --- a/src/user/authController.ts +++ b/src/user/authController.ts @@ -50,18 +50,21 @@ const createSendToken = ( // 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 // FIXME: we could type Request.body (like TypedRequestWithParam) to make sure we have certain query params -const signup = catchAsync(async (req: Request, res: Response) => { - // 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, - }); +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, + }); - createSendToken(newUser, 201, res); -}); + createSendToken(newUser, 201, res); + } +); const login = catchAsync( async (req: Request, res: Response, next: NextFunction) => { @@ -92,7 +95,7 @@ const login = catchAsync( const protect = catchAsync( async ( req: Request & { user: HydratedDocument }, - res: Response, + _res: Response, next: NextFunction ) => { // 1) Get the token and check if it exists diff --git a/src/user/userController.integration.test.ts b/src/user/userController.integration.test.ts index 6ea3971..f9b5b3f 100644 --- a/src/user/userController.integration.test.ts +++ b/src/user/userController.integration.test.ts @@ -25,7 +25,7 @@ describe('UserController', () => { mongoose.disconnect(); }); - let req: Partial & { user: Partial> }, + let req: Request & { user: HydratedDocument }, res: Partial & Partial<{ data: any; message: string }>, next: NextFunction; beforeEach(() => { @@ -53,7 +53,7 @@ describe('UserController', () => { test('should get all users', async () => { // console.log(allUsers); - await userController.getAllUsers(req as Request, res as Response); + await userController.getAllUsers(req, res as Response); console.log(res.data.users); expect(res.status).toHaveBeenCalledWith(200); @@ -91,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); @@ -133,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); @@ -152,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); @@ -182,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, { @@ -190,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); @@ -235,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/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/resultsHelper.ts b/src/utils/resultsHelper.ts index 581f602..bba554a 100644 --- a/src/utils/resultsHelper.ts +++ b/src/utils/resultsHelper.ts @@ -2,10 +2,14 @@ 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 itineraries for that destination have more than maxConnections @@ -13,10 +17,7 @@ import { RESULTS_SEARCH_LIMIT, DEFAULT_SORT_FIELD } from '../config'; * @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 = ( - source: DestinationWithItineraries | Itinerary, - maxConnections: number -) => { +const filterByMaxConnections = (source: T, maxConnections: number) => { let nbConnections = 0; if (isDestinationWithItineraries(source)) { @@ -49,8 +50,8 @@ const filterByMaxConnections = ( * @param {*} priceTo price upper limit * @returns true if flight prices are above priceFrom and below priceTo */ -const filterByPriceRange = ( - source: DestinationWithItineraries | Itinerary, +const filterByPriceRange = ( + source: T, priceFrom: number, priceTo: number ) => { @@ -128,41 +129,34 @@ const getFilters = ( * @param {*} filterParams the filters to apply to the itineraries (maxConnections, priceFrom, ...) * @returns a copy of the itineraries after filtering */ -const filter = ( - source: DestinationWithItineraries[] | Itinerary[], - filterParams: FilterParams -) => { +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 !== undefined) { - result = result.filter( - (itinerary: DestinationWithItineraries | Itinerary) => { - const filtered = filterByMaxConnections( - itinerary, - filterParams.maxConnections - ); - return filtered; - } - ); + result = result.filter((itinerary: T) => { + const filtered = filterByMaxConnections( + itinerary, + filterParams.maxConnections + ); + return filtered; + }); } // filter by price range if (filterParams.priceFrom || filterParams.priceTo) { - result = result.filter( - (itinerary: DestinationWithItineraries | Itinerary) => { - const filtered = filterByPriceRange( - itinerary, - filterParams.priceFrom, - filterParams.priceTo - ); - return filtered; - } - ); + result = result.filter((itinerary: T) => { + const filtered = filterByPriceRange( + itinerary, + filterParams.priceFrom, + filterParams.priceTo + ); + return filtered; + }); } - return result as DestinationWithItineraries[] | Itinerary[]; + return result; }; /** @@ -171,10 +165,7 @@ const filter = ( * @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 = ( - source: DestinationWithItineraries[] | Itinerary[], - filterParams: FilterParams -) => { +const paginate = (source: T[], filterParams: FilterParams) => { const page = filterParams.page ?? 1; const limit = filterParams.limit ?? RESULTS_SEARCH_LIMIT; return source.slice((page - 1) * limit, page * limit); @@ -186,8 +177,8 @@ const paginate = ( * @param {*} filterParams the filters to apply. Specifically we use filterParams.sort (default DEFAULT_SORT_FIELD) * @returns a sorted copy of the itineraries */ -const sort = ( - source: DestinationWithItineraries[] | Itinerary[], +const sort = ( + source: T[], filterParams: FilterParams ) => { const resultAfterClone = cloneDeep(source); @@ -206,10 +197,13 @@ const sort = ( * @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; @@ -220,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 @@ -228,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]); @@ -245,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; diff --git a/src/utils/resultsHelper.unit.test.ts b/src/utils/resultsHelper.unit.test.ts index 52b2afe..2fcd9ca 100644 --- a/src/utils/resultsHelper.unit.test.ts +++ b/src/utils/resultsHelper.unit.test.ts @@ -4,11 +4,15 @@ 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 DESTINATION_WITH_ITINERARIES: Partial = { @@ -374,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( @@ -394,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( @@ -404,7 +408,8 @@ describe('Results Helper', () => { }); describe('buildNavigationUrlsFromRequest', () => { - let req, route; + let req: Partial>; + let route: string; beforeEach(() => { req = { body: { @@ -422,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 ); @@ -442,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 ); @@ -452,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 ); @@ -461,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 ); @@ -470,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/validator.unit.test.ts b/src/utils/validator.unit.test.ts index 4150cd7..82c9408 100644 --- a/src/utils/validator.unit.test.ts +++ b/src/utils/validator.unit.test.ts @@ -1,53 +1,55 @@ -import validator from './validator'; -import { isAlpha, isDate, isNumeric } from 'validator/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); }); }); @@ -78,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 = { @@ -86,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' ); }); @@ -99,15 +101,15 @@ 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[]; @@ -117,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`, () => { @@ -126,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', ]); }); @@ -137,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', ]); }); @@ -148,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', ]); }); @@ -159,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/viewController.ts b/src/views/viewController.ts index 22c0058..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,72 +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 + ); - // FIXME: once we migrated viewController to TS, we need to update prepareSeveralOriginAPIParamsFromView - 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/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. */ },