diff --git a/app.js b/app.js index cb096c6..c257810 100644 --- a/app.js +++ b/app.js @@ -3,33 +3,16 @@ * Configures and exports the Express app instance */ -import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import swaggerUi from 'swagger-ui-express'; -import yaml from 'js-yaml'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; -import mongoose from 'mongoose'; - -// Import middleware -import errorHandler from './middleware/errorHandler.js'; -import { requestLogger } from './middleware/logger.js'; -import requestId from './middleware/requestId.js'; - -// Import centralized routes +// External dependencies (4 consolidated into 1) +import { express, cors, helmet, mongoose } from './dependencies.js'; +// Middleware +import { errorHandler, requestLogger, requestId } from './middleware/index.js'; +// Configuration +import { setupSwagger, API_VERSION } from './config/index.js'; +// Routes import routes from './routes/index.js'; -// Import constants -import { API_VERSION } from './config/constants.js'; -// Load Swagger YAML -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const swaggerDocument = yaml.load( - readFileSync(join(__dirname, 'docs', 'swagger.yaml'), 'utf8') -); const app = express(); @@ -87,11 +70,7 @@ app.use(requestLogger); * Swagger API Documentation * Serves interactive API documentation at /api-docs */ -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, { - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: 'myWorld Travel API Documentation', - customfavIcon: '/favicon.ico' -})); +setupSwagger(app); /** * Root endpoint with minimal HATEOAS links diff --git a/config/database.js b/config/database.js index eb09ed0..3d6b41f 100644 --- a/config/database.js +++ b/config/database.js @@ -7,7 +7,9 @@ import initialData from './seedData.js'; let isConnected = false; +// Establish MongoDB connection and seed initial data if needed const connectDB = async () => { + // Skip if already connected if (isConnected) { console.log('✓ Already connected to MongoDB'); return; } const mongoURI = process.env.MONGODB_URI; @@ -25,6 +27,7 @@ const connectDB = async () => { } }; +// Close MongoDB connection gracefully const disconnectDB = async () => { if (!isConnected) return; try { @@ -37,14 +40,17 @@ const disconnectDB = async () => { } }; +// Populate database with initial seed data from JSON files const seedInitialData = async () => { try { + // Skip if data already exists if (await models.User.countDocuments() > 0) { console.log('✓ Database already contains data. Skipping seeding.'); return; } console.log('📦 Seeding initial data...'); + // Insert all entity types await models.User.insertMany(initialData.users); await models.Place.insertMany(initialData.places); await models.PreferenceProfile.insertMany(initialData.preferenceProfiles); @@ -54,6 +60,7 @@ const seedInitialData = async () => { await models.DislikedPlace.insertMany(initialData.dislikedPlaces); await models.Settings.insertMany(initialData.settings); + // Initialize ID counters for auto-increment const counters = Object.entries(initialData.counters).map(([name, value]) => ({ name, value })); await models.Counter.insertMany(counters); console.log('✓ Initial data seeding completed successfully!'); @@ -63,17 +70,21 @@ const seedInitialData = async () => { } }; +// Generate next sequential ID for a counter (auto-increment) const getNextId = async (counterName) => { const counter = await models.Counter.findOneAndUpdate({ name: counterName }, { $inc: { value: 1 } }, { new: true, upsert: true }); return counter.value; }; +// Remove all data from database (used in testing) const clearAllData = async () => { try { + // Skip if database not connected if (mongoose.connection.readyState !== 1) { console.warn('⚠️ clearAllData called when MongoDB is not connected; skipping'); return; } + // Delete all documents from all collections await Promise.all([ models.User.deleteMany({}), models.Place.deleteMany({}), models.PreferenceProfile.deleteMany({}), models.Review.deleteMany({}), models.Report.deleteMany({}), models.FavouritePlace.deleteMany({}), diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..2d5181b --- /dev/null +++ b/config/index.js @@ -0,0 +1,9 @@ +/** + * Config Index - Centralized config exports + * Consolidates configuration exports to reduce import statements + */ + +export { setupSwagger } from './swagger.js'; +export { API_VERSION } from './constants.js'; +export { default as db } from './db.js'; +export { default as database } from './database.js'; diff --git a/config/seedData/index.js b/config/seedData/index.js index 3f06cb2..d042a37 100644 --- a/config/seedData/index.js +++ b/config/seedData/index.js @@ -6,10 +6,12 @@ import bcrypt from 'bcryptjs'; const require = createRequire(import.meta.url); +// Load JSON data file from relative path function loadJsonData(path) { return require(path); } +// Hash passwords for all users using bcrypt function hashUserPasswords(usersArray) { return usersArray.map(user => ({ ...user, @@ -18,6 +20,7 @@ function hashUserPasswords(usersArray) { })); } +// Initialize counter values for auto-increment IDs function createCounters() { return { userId: 20000, @@ -30,11 +33,13 @@ function createCounters() { }; } +// Load all JSON seed data files const placesData = loadJsonData('./places.json'); const placesExtendedData = loadJsonData('./placesExtended.json'); const interactionsData = loadJsonData('./interactions.json'); const usersData = loadJsonData('./users.json'); +// Prepare data arrays for database insertion let places = [...placesData]; let placesExtended = [...placesExtendedData]; let { reviews, reports, favouritePlaces, dislikedPlaces } = interactionsData; diff --git a/config/swagger.js b/config/swagger.js new file mode 100644 index 0000000..3c2ddf1 --- /dev/null +++ b/config/swagger.js @@ -0,0 +1,32 @@ +/** + * Swagger Documentation Configuration + * Sets up Swagger UI for API documentation + */ + +import swaggerUi from 'swagger-ui-express'; +import yaml from 'js-yaml'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +/** + * Configure and mount Swagger UI on the express app + * @param {Object} app - Express application instance + */ +export const setupSwagger = (app) => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + + // Go up one level from config/ to root/ to find docs/ + const rootDir = join(__dirname, '..'); + + const swaggerDocument = yaml.load( + readFileSync(join(rootDir, 'docs', 'swagger.yaml'), 'utf8') + ); + + app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, { + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'myWorld Travel API Documentation', + customfavIcon: '/favicon.ico' + })); +}; diff --git a/controllers/adminController.js b/controllers/adminController.js index 5e32e41..0949721 100644 --- a/controllers/adminController.js +++ b/controllers/adminController.js @@ -9,6 +9,29 @@ import jwt from 'jsonwebtoken'; import R from '../utils/responseBuilder.js'; import { requirePlace } from '../utils/controllerValidators.js'; +// --- Helper Functions (Private) --- + +/** Allowed fields for place updates */ +const ALLOWED_PLACE_FIELDS = ['name', 'description', 'category', 'website']; + +/** Prepare update DTO from request body, picking only allowed fields */ +const preparePlaceUpdateDTO = (body, existingLocation) => { + const updateData = {}; + ALLOWED_PLACE_FIELDS.forEach(k => { if (body[k]) updateData[k] = body[k]; }); + if (body.location) updateData.location = { ...existingLocation, ...body.location }; + return updateData; +}; + +/** Add HATEOAS links to each report */ +const enrichReportsWithLinks = (reports, placeId, adminId) => { + return reports.map(report => { + const reportObj = report.toObject ? report.toObject() : report; + return { ...reportObj, links: buildHateoasLinks.adminReport(placeId, adminId) }; + }); +}; + +// --- Controllers --- + const getPlaceReports = async (req, res, next) => { try { const adminId = parseInt(req.params.adminId); @@ -18,10 +41,7 @@ const getPlaceReports = async (req, res, next) => { if (!place) return; const placeReports = await db.getReportsForPlace(placeId); - const reportsWithLinks = placeReports.map(report => { - const reportObj = report.toObject ? report.toObject() : report; - return { ...reportObj, links: buildHateoasLinks.adminReport(placeId, adminId) }; - }); + const reportsWithLinks = enrichReportsWithLinks(placeReports, placeId, adminId); return R.success(res, { reports: reportsWithLinks, totalReports: reportsWithLinks.length, links: buildHateoasLinks.adminReportsCollection(adminId, placeId) }, 'Place reports retrieved successfully'); } catch (error) { next(error); } @@ -36,9 +56,7 @@ const updatePlace = async (req, res, next) => { if (!place) return; const placeObj = place.toObject ? place.toObject() : place; - const updateData = {}; - ['name', 'description', 'category', 'website'].forEach(k => { if (req.body[k]) updateData[k] = req.body[k]; }); - if (req.body.location) updateData.location = { ...placeObj.location, ...req.body.location }; + const updateData = preparePlaceUpdateDTO(req.body, placeObj.location); const updatedPlace = await db.updatePlace(placeId, updateData); const updatedPlaceObj = updatedPlace.toObject ? updatedPlace.toObject() : updatedPlace; @@ -68,3 +86,4 @@ const generateAdminToken = (req, res, next) => { }; export default { getPlaceReports, updatePlace, generateAdminToken }; + diff --git a/controllers/favouriteController.js b/controllers/favouriteController.js index cd1fc98..5401edf 100644 --- a/controllers/favouriteController.js +++ b/controllers/favouriteController.js @@ -10,13 +10,16 @@ import { requireUser, requireUserAndPlace } from '../utils/controllerValidators. export const validateUserAndPlace = requireUserAndPlace; +// Get all favourite places for a user with HATEOAS links const getFavouritePlaces = async (req, res, next) => { try { const userId = parseInt(req.params.userId); + // Validate user exists const user = await requireUser(res, userId); if (!user) return; const favouritePlaces = await db.getFavouritePlaces(userId); + // Attach HATEOAS links to each favourite const favouritePlacesWithLinks = favouritePlaces.map(fav => { const place = fav.place || {}; return { @@ -32,13 +35,16 @@ const getFavouritePlaces = async (req, res, next) => { } catch (error) { next(error); } }; +// Add a place to user's favourites list const addFavouritePlace = async (req, res, next) => { try { const userId = parseInt(req.params.userId); + // Validate both user and place exist const validation = await requireUserAndPlace(res, userId, req.body.placeId); if (!validation) return; const newFavourite = await db.addFavouritePlace(userId, req.body.placeId); + // Check if place is already in favourites if (!newFavourite) { return R.conflict(res, 'PLACE_ALREADY_FAVORITED', "Place is already in user's favorites", { placeId: req.body.placeId, userId }); } @@ -54,6 +60,7 @@ const addFavouritePlace = async (req, res, next) => { } catch (error) { next(error); } }; +// Remove a place from user's favourites by favouriteId const removeFavouritePlace = async (req, res, next) => { try { const userId = parseInt(req.params.userId); @@ -62,6 +69,7 @@ const removeFavouritePlace = async (req, res, next) => { const user = await requireUser(res, userId); if (!user) return; + // Attempt to remove favourite const removed = await db.removeFavouritePlace(userId, favouriteId); if (!removed) { return R.notFound(res, 'FAVOURITE_NOT_FOUND', `Favourite with ID ${favouriteId} not found for user ${userId}`); diff --git a/controllers/navigationController.js b/controllers/navigationController.js index 06f9819..ca75fdb 100644 --- a/controllers/navigationController.js +++ b/controllers/navigationController.js @@ -8,61 +8,133 @@ import buildHateoasLinks from '../utils/hateoasBuilder.js'; import { calculateDistance } from '../utils/geoUtils.js'; import R from '../utils/responseBuilder.js'; -/** Supported transportation modes */ -const MODES = ['WALKING', 'DRIVING', 'PUBLIC_TRANSPORT']; +/** Supported transportation modes with their respective speeds in km/h */ +const TRANSPORTATION = { + WALKING: { speed: 5 }, + DRIVING: { speed: 50 }, + PUBLIC_TRANSPORT: { speed: 30 } +}; -/** Speed in km/h for each mode */ -const SPEEDS = { WALKING: 5, DRIVING: 50, PUBLIC_TRANSPORT: 30 }; +/** Extract valid transportation modes */ +const VALID_MODES = Object.keys(TRANSPORTATION); -/** Validate location coordinates */ -const validateLocation = (res, loc) => { - if (!loc.lat || !loc.lon) { - R.badRequest(res, 'INVALID_INPUT', `${loc.name} location required`); - return false; +/** + * Parse and validate coordinates + * @param {string} latitude - Latitude as string + * @param {string} longitude - Longitude as string + * @returns {Object|null} Parsed coordinates or null if invalid + */ +const parseCoordinates = (latitude, longitude) => { + if (!latitude || !longitude) { + return null; } - return true; -}; -/** Validate transportation mode */ -const validateMode = (res, mode) => { - if (!MODES.includes(mode)) { - R.badRequest(res, 'INVALID_INPUT', `Invalid mode: ${mode}`); - return false; + const lat = parseFloat(latitude); + const lon = parseFloat(longitude); + + if (isNaN(lat) || isNaN(lon)) { + return null; } - return true; + + return { lat, lon }; +}; + +/** + * Validate transportation mode + * @param {string} mode - Transportation mode to validate + * @returns {boolean} True if mode is valid + */ +const isValidMode = (mode) => VALID_MODES.includes(mode); + +/** + * Calculate estimated travel time in minutes + * @param {number} distance - Distance in kilometers + * @param {string} mode - Transportation mode + * @returns {number} Estimated time in minutes (rounded up) + */ +const calculateTravelTime = (distance, mode) => { + const speed = TRANSPORTATION[mode].speed; + return Math.ceil((distance / speed) * 60); }; -/** Build route response object */ -const buildRoute = (start, end, options) => ({ - startPoint: { latitude: start.lat, longitude: start.lon }, - endPoint: { latitude: end.lat, longitude: end.lon }, - transportationMode: options.mode, - estimatedTime: Math.ceil((options.distance / SPEEDS[options.mode]) * 60), - distance: options.distance +/** + * Build route response object + * @param {Object} start - Start coordinates {lat, lon} + * @param {Object} end - End coordinates {lat, lon} + * @param {string} mode - Transportation mode + * @param {number} distance - Distance in kilometers + * @returns {Object} Route information object + */ +const buildRouteResponse = (start, end, mode, distance) => ({ + startPoint: { + latitude: start.lat, + longitude: start.lon + }, + endPoint: { + latitude: end.lat, + longitude: end.lon + }, + transportationMode: mode, + estimatedTime: calculateTravelTime(distance, mode), + distance }); -/** GET /navigation - Calculate route between two points */ +/** + * GET /navigation - Calculate route between two points + * Validates input parameters, calculates distance and travel time + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Function} next - Express next middleware function + */ const getNavigation = async (req, res, next) => { try { - const { userLatitude, userLongitude, placeLatitude, placeLongitude, transportationMode } = req.query; + const { + userLatitude, + userLongitude, + placeLatitude, + placeLongitude, + transportationMode + } = req.query; - const userLoc = { lat: userLatitude, lon: userLongitude, name: 'user' }; - const placeLoc = { lat: placeLatitude, lon: placeLongitude, name: 'place' }; + // Parse and validate user coordinates + const userCoords = parseCoordinates(userLatitude, userLongitude); + if (!userCoords) { + return R.badRequest(res, 'INVALID_INPUT', 'Valid user location coordinates required'); + } - if (!validateLocation(res, userLoc)) return; - if (!validateLocation(res, placeLoc)) return; + // Parse and validate place coordinates + const placeCoords = parseCoordinates(placeLatitude, placeLongitude); + if (!placeCoords) { + return R.badRequest(res, 'INVALID_INPUT', 'Valid place location coordinates required'); + } + // Validate transportation mode (default to WALKING) const mode = transportationMode || 'WALKING'; - if (!validateMode(res, mode)) return; + if (!isValidMode(mode)) { + return R.badRequest( + res, + 'INVALID_INPUT', + `Invalid transportation mode. Must be one of: ${VALID_MODES.join(', ')}` + ); + } + + // Calculate distance between coordinates + const distance = calculateDistance( + { latitude: userCoords.lat, longitude: userCoords.lon }, + { latitude: placeCoords.lat, longitude: placeCoords.lon } + ); - const start = { lat: parseFloat(userLatitude), lon: parseFloat(userLongitude) }; - const end = { lat: parseFloat(placeLatitude), lon: parseFloat(placeLongitude) }; - const distance = calculateDistance(start.lat, start.lon, end.lat, end.lon); + // Build complete route response + const route = buildRouteResponse(userCoords, placeCoords, mode, distance); - const route = buildRoute(start, end, { mode, distance }); - return R.success(res, { route, links: buildHateoasLinks.navigation() }, 'Route calculated'); - } catch (e) { - next(e); + // Return success response with route data and HATEOAS links + return R.success( + res, + { route, links: buildHateoasLinks.navigation() }, + 'Route calculated successfully' + ); + } catch (error) { + next(error); } }; diff --git a/controllers/placeController.js b/controllers/placeController.js index 29fe883..c2c5cad 100644 --- a/controllers/placeController.js +++ b/controllers/placeController.js @@ -10,6 +10,28 @@ import R from '../utils/responseBuilder.js'; import { requirePlace } from '../utils/controllerValidators.js'; import placeWrite from './placeWrite.js'; +// --- Helper Functions (Private) --- + +/** Check if search terms contain injection characters */ +const hasInvalidCharacters = (terms) => { + const injectionPattern = /['";${}]/; + return terms.some(term => injectionPattern.test(term)); +}; + +/** Enrich places with reviews and HATEOAS links */ +const enrichPlacesWithDetails = async (places) => { + return Promise.all(places.map(async (place) => { + const placeObj = place.toObject ? place.toObject() : place; + return { + ...placeObj, + reviews: await db.getReviewsForPlace(placeObj.placeId), + links: buildHateoasLinks.selectLink(placeObj.placeId) + }; + })); +}; + +// --- Controllers --- + /** GET /places/:placeId - Retrieve place details with reviews */ const getPlace = async (req, res, next) => { try { @@ -45,27 +67,16 @@ const performSearch = async (req, res, next) => { const searchTerms = Array.isArray(keywords) ? keywords : [keywords]; // Input validation: Reject potential injection characters - // This prevents SQL/NoSQL injection attacks and ZAP false positives - const injectionPattern = /['"();${}]/; - for (const term of searchTerms) { - if (injectionPattern.test(term)) { - return R.badRequest(res, 'INVALID_INPUT', 'Search keywords contain invalid characters'); - } + if (hasInvalidCharacters(searchTerms)) { + return R.badRequest(res, 'INVALID_INPUT', 'Search keywords contain invalid characters'); } const results = await db.searchPlaces(searchTerms); - - const resultsWithDetails = await Promise.all(results.map(async (place) => { - const placeObj = place.toObject ? place.toObject() : place; - return { - ...placeObj, - reviews: await db.getReviewsForPlace(placeObj.placeId), - links: buildHateoasLinks.selectLink(placeObj.placeId) - }; - })); + const resultsWithDetails = await enrichPlacesWithDetails(results); return R.success(res, { results: resultsWithDetails, searchTerms, totalResults: resultsWithDetails.length, links: buildHateoasLinks.search() }, 'Search completed successfully'); } catch (error) { next(error); } }; export default { getPlace, getReviews, submitReview: placeWrite.submitReview, createReport: placeWrite.createReport, performSearch }; + diff --git a/controllers/preferenceCreate.js b/controllers/preferenceCreate.js index cb2b647..e69abf6 100644 --- a/controllers/preferenceCreate.js +++ b/controllers/preferenceCreate.js @@ -10,6 +10,7 @@ import { requireUser } from '../utils/controllerValidators.js'; export const VALID_CATEGORIES = ['MUSEUM', 'BEACH', 'PARK', 'RESTAURANT', 'NIGHTLIFE', 'SHOPPING', 'SPORTS', 'CULTURE']; +// Normalize profile objects to consistent format export const normalizeProfiles = (profiles) => (profiles || []).map(p => ({ profileId: p.profileId || p.id, name: p.name || p.profileName, @@ -18,9 +19,11 @@ export const normalizeProfiles = (profiles) => (profiles || []).map(p => ({ userId: p.userId })); +// Extract categories from request body (supports multiple field names) const getCategories = (body) => Array.isArray(body.categories) ? body.categories : body.selectedPreferences; const getFieldName = (body) => Array.isArray(body.categories) ? 'categories' : 'selectedPreferences'; +// Validate category array contains valid values const validateCategories = (res, cats, field) => { if (!Array.isArray(cats) || cats.length === 0) { R.badRequest(res, 'INVALID_PROFILE_DATA', 'No preferences selected', { field }); @@ -34,31 +37,38 @@ const validateCategories = (res, cats, field) => { return true; }; +// Generate unique profile name by appending counter if name exists const generateUniqueName = (baseName, existingProfiles) => { const names = new Set((existingProfiles || []).map(p => (p.name || '').trim())); if (!names.has(baseName)) return baseName; + // Add incrementing suffix until name is unique let c = 2; while (names.has(`${baseName} (${c})`)) c++; return `${baseName} (${c})`; }; +// Create new preference profile for user const createPreferenceProfile = async (req, res, next) => { try { const userId = parseInt(req.params.userId); if (!await requireUser(res, userId)) return; + // Validate category selection const cats = getCategories(req.body); const field = getFieldName(req.body); if (!validateCategories(res, cats, field)) return; + // Validate profile name if provided if (req.body.profileName && typeof req.body.profileName !== 'string') { return R.badRequest(res, 'INVALID_PROFILE_DATA', 'Name must be a string', { field: 'profileName' }); } + // Generate unique profile name const existing = await db.getPreferenceProfiles(userId); const baseName = (req.body.profileName || '').trim() || `Profile ${(existing?.length || 0) + 1}`; const name = generateUniqueName(baseName, existing); + // Create profile and return all profiles await db.addPreferenceProfile({ userId, name, categories: cats }); const all = await db.getPreferenceProfiles(userId); diff --git a/controllers/preferenceModify.js b/controllers/preferenceModify.js index 9c36819..9d025ad 100644 --- a/controllers/preferenceModify.js +++ b/controllers/preferenceModify.js @@ -11,6 +11,74 @@ import { VALID_CATEGORIES } from './preferenceCreate.js'; const normalizeProfiles = (profiles) => (profiles || []).map(p => ({ ...p, categories: Array.isArray(p.categories) ? p.categories : [] })); +// ============================================================================= +// Helper Functions - Extract validation and payload normalization logic +// ============================================================================= + +/** + * Extract category data from request body + * @param {Object} body - Request body + * @returns {Object|null} Object with categories and fieldName, or null if no categories present + */ +const extractCategories = (body) => { + if (Array.isArray(body.categories)) return { categories: body.categories, fieldName: 'categories' }; + if (Array.isArray(body.selectedPreferences)) return { categories: body.selectedPreferences, fieldName: 'selectedPreferences' }; + return null; +}; + +/** + * Validate category input + * @param {Object} res - Express response object + * @param {Object|null} categoryData - Extracted category data + * @returns {boolean} True if valid or not present, false if invalid (response sent) + */ +const validateCategoryInput = (res, categoryData) => { + if (!categoryData) return true; // No categories to validate + + const { categories, fieldName } = categoryData; + + if (categories.length === 0) { + R.badRequest(res, 'INVALID_PROFILE_DATA', 'No preferences selected', + { field: fieldName, value: [], reason: 'At least one preference category must be selected' }); + return false; + } + + const invalidCategories = categories.filter(pref => !VALID_CATEGORIES.includes(pref)); + if (invalidCategories.length > 0) { + R.badRequest(res, 'INVALID_PROFILE_DATA', 'Invalid preference categories', + { field: 'categories', invalidValues: invalidCategories }); + return false; + } + + return true; +}; + +/** + * Normalize the update payload + * @param {Object} body - Original request body + * @param {Array} categories - Validated categories (optional) + * @returns {Object} Normalized payload + */ +const normalizeUpdatePayload = (body, categories) => { + const payload = { ...body }; + if (categories) payload.categories = categories; + + // Normalize profile name field + if ('profileName' in payload) { + payload.name = payload.profileName; + delete payload.profileName; + } + + // Remove legacy field + delete payload.selectedPreferences; + + return payload; +}; + +// ============================================================================= +// Controllers +// ============================================================================= + const updatePreferenceProfile = async (req, res, next) => { try { const userId = parseInt(req.params.userId); @@ -19,24 +87,12 @@ const updatePreferenceProfile = async (req, res, next) => { const user = await requireUser(res, userId); if (!user) return; - const incomingCategories = Array.isArray(req.body.categories) ? req.body.categories : Array.isArray(req.body.selectedPreferences) ? req.body.selectedPreferences : undefined; - const fieldName = Array.isArray(req.body.categories) ? 'categories' : 'selectedPreferences'; - - if (incomingCategories !== undefined && incomingCategories.length === 0) { - return R.badRequest(res, 'INVALID_PROFILE_DATA', 'No preferences selected', { field: fieldName, value: [], reason: 'At least one preference category must be selected' }); - } - - if (incomingCategories !== undefined) { - const invalidCategories = incomingCategories.filter(pref => !VALID_CATEGORIES.includes(pref)); - if (invalidCategories.length > 0) { - return R.badRequest(res, 'INVALID_PROFILE_DATA', 'Invalid preference categories', { field: 'categories', invalidValues: invalidCategories }); - } - } + // Extract and validate categories + const categoryData = extractCategories(req.body); + if (!validateCategoryInput(res, categoryData)) return; - const updatePayload = { ...req.body }; - if (incomingCategories !== undefined) updatePayload.categories = incomingCategories; - if ('profileName' in updatePayload) { updatePayload.name = updatePayload.profileName; delete updatePayload.profileName; } - if ('selectedPreferences' in updatePayload) delete updatePayload.selectedPreferences; + // Prepare update payload + const updatePayload = normalizeUpdatePayload(req.body, categoryData?.categories); const updatedProfile = await db.updatePreferenceProfile(userId, profileId, updatePayload); if (!updatedProfile) { @@ -44,7 +100,10 @@ const updatePreferenceProfile = async (req, res, next) => { } const allProfiles = await db.getPreferenceProfiles(userId); - return R.success(res, { profiles: normalizeProfiles(allProfiles), links: buildHateoasLinks.preferenceProfilesCollection(userId) }, 'Preference profile updated successfully'); + return R.success(res, { + profiles: normalizeProfiles(allProfiles), + links: buildHateoasLinks.preferenceProfilesCollection(userId) + }, 'Preference profile updated successfully'); } catch (error) { next(error); } }; diff --git a/controllers/recommendationController.js b/controllers/recommendationController.js index af71f75..80bc7d8 100644 --- a/controllers/recommendationController.js +++ b/controllers/recommendationController.js @@ -7,145 +7,138 @@ import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import { calculateDistance } from '../utils/geoUtils.js'; -/** - * Get personalized recommendations for a user - * GET /users/:userId/recommendations - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @param {Function} next - Express next middleware function - * @returns {Promise} - */ -const getRecommendations = async (req, res, next) => { - try { - const userId = parseInt(req.params.userId); - const user = await db.findUserById(userId); +// --- Helper Functions (Private) --- - if (!user) { - return res.status(404).json({ - success: false, - data: null, - error: 'USER_NOT_FOUND', - message: `User with ID ${userId} not found` - }); - } +/** Determine the active profile for the user */ +const resolveActiveProfile = (profiles, userObj) => { + if (!profiles || profiles.length === 0) return null; + // Use active profile, or fallback to the most recent one + return profiles.find(p => p.profileId === userObj.activeProfile) || profiles[profiles.length - 1]; +}; - const userObj = user.toObject ? user.toObject() : user; +/** Filter places based on categories and remove disliked ones */ +const filterPlaces = (allPlaces, dislikedIds) => { + return allPlaces.filter(place => { + const placeObj = place.toObject ? place.toObject() : place; + return !dislikedIds.includes(placeObj.placeId); + }); +}; - const profiles = await db.getPreferenceProfiles(userId); +// --- Sorting Helpers --- - if (!profiles || profiles.length === 0) { - return res.json({ - success: true, - data: { - recommendations: [], - links: buildHateoasLinks.recommendations(userId) - }, - message: 'Δημιουργήστε ένα προφίλ προτιμήσεων για να δείτε προτάσεις', - error: null +/** + * Partition places into those with and without valid location data + * @param {Array} places - List of places + * @param {number} userLat - User latitude + * @param {number} userLon - User longitude + * @returns {Object} { withLoc, withoutLoc } - Partitioned places + */ +const partitionByLocation = (places, userLat, userLon) => { + const withLoc = []; + const withoutLoc = []; + + places.forEach(place => { + if (place.location?.latitude && place.location?.longitude) { + withLoc.push({ + ...place, + distance: calculateDistance( + { latitude: userLat, longitude: userLon }, + { latitude: place.location.latitude, longitude: place.location.longitude } + ) }); + } else { + withoutLoc.push(place); } + }); - // Find active profile, or fallback to the first/most recent profile - let activeProfile = profiles.find(p => p.profileId === userObj.activeProfile); + return { withLoc, withoutLoc }; +}; - if (!activeProfile) { - // If no active profile is set, use the most recently created one (last in array) - activeProfile = profiles[profiles.length - 1]; - console.log(`No active profile found for user ${userId}, using profile ${activeProfile.profileId}`); - } +/** Sort places by rating (descending) */ +const sortByRating = (places) => [...places].sort((a, b) => (b.rating || 0) - (a.rating || 0)); - const profileObj = activeProfile.toObject ? activeProfile.toObject() : activeProfile; +/** Sort places by distance (ascending) */ +const sortByDistance = (places) => [...places].sort((a, b) => a.distance - b.distance); - // Get user's disliked places - const dislikedPlaces = await db.getDislikedPlaces(userId); - const dislikedPlaceIds = dislikedPlaces.map(d => d.placeId); +/** Sort places by distance (if coords provided) or rating */ +const sortPlaces = (places, { latitude, longitude, maxDistance }) => { + const placeList = places.map(p => p.toObject ? p.toObject() : p); - // Get selected preferences (could be in 'categories' or 'selectedPreferences') - const selectedCategories = profileObj.categories || profileObj.selectedPreferences || []; + if (!latitude || !longitude) { + return sortByRating(placeList); + } - console.log(`User ${userId} - Selected categories:`, selectedCategories); + const userLat = parseFloat(latitude); + const userLon = parseFloat(longitude); + const maxDist = maxDistance ? parseFloat(maxDistance) : null; - // Filter places based on preferences and exclude disliked places - let recommendedPlaces = await db.getPlacesByCategories(selectedCategories); + // Calculate distances and separate places with/without location + const { withLoc, withoutLoc } = partitionByLocation(placeList, userLat, userLon); - console.log(`Found ${recommendedPlaces.length} places matching categories`); + // Sort by distance, filter by maxDist, then append places without location (sorted by rating) + const sortedWithLoc = sortByDistance(withLoc); + const filteredWithLoc = maxDist ? sortedWithLoc.filter(p => p.distance <= maxDist) : sortedWithLoc; + const sortedWithoutLoc = sortByRating(withoutLoc); - recommendedPlaces = recommendedPlaces.filter(place => { - const placeObj = place.toObject ? place.toObject() : place; - return !dislikedPlaceIds.includes(placeObj.placeId); - }); + return [...filteredWithLoc, ...sortedWithoutLoc]; +}; - console.log(`After filtering disliked: ${recommendedPlaces.length} places`); - - // Get current location from query params (if provided, calculate distance) - const { latitude, longitude, maxDistance } = req.query; - - // First, normalize all places to plain objects - recommendedPlaces = recommendedPlaces.map(place => place.toObject ? place.toObject() : place); - - if (latitude && longitude) { - const userLat = parseFloat(latitude); - const userLon = parseFloat(longitude); - const maxDist = maxDistance ? parseFloat(maxDistance) : null; - - // Separate places with and without location data - const placesWithLocation = []; - const placesWithoutLocation = []; - - recommendedPlaces.forEach(place => { - if (place.location && - typeof place.location.latitude === 'number' && - typeof place.location.longitude === 'number') { - placesWithLocation.push({ - ...place, - distance: calculateDistance( - userLat, - userLon, - place.location.latitude, - place.location.longitude - ) - }); - } else { - placesWithoutLocation.push(place); - } - }); +/** Fetch reviews and build links for the final list */ +const hydrateRecommendations = async (places) => { + return Promise.all(places.map(async (place) => { + const reviews = await db.getReviewsForPlace(place.placeId); + return { + ...place, + reviews, + links: buildHateoasLinks.selectLink(place.placeId) + }; + })); +}; - // Sort places with location by distance - placesWithLocation.sort((a, b) => a.distance - b.distance); +// --- Main Controller --- - // Filter by maxDistance if specified - const filteredByDistance = maxDist - ? placesWithLocation.filter(place => place.distance <= maxDist) - : placesWithLocation; +const getRecommendations = async (req, res, next) => { + try { + const userId = parseInt(req.params.userId); + const user = await db.findUserById(userId); - // Combine: places with location first (sorted by distance), then places without location (sorted by rating) - placesWithoutLocation.sort((a, b) => (b.rating || 0) - (a.rating || 0)); + if (!user) { + return res.status(404).json({ success: false, data: null, error: 'USER_NOT_FOUND', message: `User with ID ${userId} not found` }); + } - recommendedPlaces = [...filteredByDistance, ...placesWithoutLocation]; + const userObj = user.toObject ? user.toObject() : user; + const profiles = await db.getPreferenceProfiles(userId); + const activeProfile = resolveActiveProfile(profiles, userObj); - console.log(`Places with location: ${placesWithLocation.length}, without location: ${placesWithoutLocation.length}`); - } else { - // Sort by rating if no location provided - recommendedPlaces.sort((a, b) => (b.rating || 0) - (a.rating || 0)); + if (!activeProfile) { + return res.json({ + success: true, + data: { recommendations: [], links: buildHateoasLinks.recommendations(userId) }, + message: 'Create a preference profile to see recommendations', + error: null + }); } - // Limit to top 10 recommendations - recommendedPlaces = recommendedPlaces.slice(0, 10); + // 1. Gather Data + const profileObj = activeProfile.toObject ? activeProfile.toObject() : activeProfile; + const dislikedPlaces = await db.getDislikedPlaces(userId); + const categories = profileObj.categories || profileObj.selectedPreferences || []; - // Add reviews to each place - const recommendationsWithDetails = await Promise.all(recommendedPlaces.map(async (place) => { - const placeReviews = await db.getReviewsForPlace(place.placeId); - return { - ...place, - reviews: placeReviews, - links: buildHateoasLinks.selectLink(place.placeId) - }; - })); + // 2. Fetch & Filter + const rawPlaces = await db.getPlacesByCategories(categories); + const filteredPlaces = filterPlaces(rawPlaces, dislikedPlaces.map(d => d.placeId)); + + // 3. Sort & Limit + const sortedPlaces = sortPlaces(filteredPlaces, req.query); + const topPlaces = sortedPlaces.slice(0, 10); + + // 4. Hydrate (Reviews/Links) + const finalRecommendations = await hydrateRecommendations(topPlaces); res.json({ success: true, data: { - recommendations: recommendationsWithDetails, + recommendations: finalRecommendations, activeProfile: profileObj.name || profileObj.profileName, links: buildHateoasLinks.recommendations(userId) }, @@ -157,6 +150,4 @@ const getRecommendations = async (req, res, next) => { } }; -export default { - getRecommendations -}; +export default { getRecommendations }; diff --git a/dependencies.js b/dependencies.js new file mode 100644 index 0000000..3d1f04d --- /dev/null +++ b/dependencies.js @@ -0,0 +1,9 @@ +/** + * Dependencies Index - Third-party package exports + * Consolidates external dependencies to reduce import statements + */ + +export { default as express } from 'express'; +export { default as cors } from 'cors'; +export { default as helmet } from 'helmet'; +export { default as mongoose } from 'mongoose'; diff --git a/eslint.config.js b/eslint.config.js index b0a9732..663f501 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,21 +1,22 @@ /** ESLint Configuration */ import js from "@eslint/js"; import globals from "globals"; -import { defineConfig } from "eslint/config"; -export default defineConfig([ +export default [ { ignores: ["node_modules/**", "coverage/**"] }, { files: ["**/*.{js,mjs,cjs}"], - plugins: { js }, - extends: ["js/recommended"], + ...js.configs.recommended, languageOptions: { globals: globals.node }, - rules: { "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] } + rules: { + ...js.configs.recommended.rules, + "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] + } }, { files: ["tests/**/*.{js,mjs,cjs}", "**/*.test.{js,mjs,cjs}"], - languageOptions: { globals: { ...globals.jest, ...globals.node } } + languageOptions: { + globals: { ...globals.jest, ...globals.node } + } } -]); - - +]; \ No newline at end of file diff --git a/k6/helpers/recommendations-helper.js b/k6/helpers/recommendations-helper.js new file mode 100644 index 0000000..88aade5 --- /dev/null +++ b/k6/helpers/recommendations-helper.js @@ -0,0 +1,81 @@ +/** + * Shared helper for recommendations endpoint k6 tests + * + * Contains common configuration, setup, and request logic + * used by both load and spike tests. + */ + +/* global __ENV */ +import http from 'k6/http'; +import { check } from 'k6'; + +// Configuration +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; + +// Test user credentials (from in-memory database) +export const TEST_USER = { + email: 'user1@example.com', + password: 'password123', + userId: 16180 +}; + +/** + * Setup function - authenticates and returns token + userId + * Use this in your test's setup() export + */ +export function authenticateTestUser() { + const loginRes = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ + email: TEST_USER.email, + password: TEST_USER.password + }), + { + headers: { 'Content-Type': 'application/json' } + } + ); + + check(loginRes, { + 'login successful': (r) => r.status === 200, + }); + + const body = JSON.parse(loginRes.body); + return { token: body.data.token, userId: TEST_USER.userId }; +} + +/** + * Makes a GET request to the recommendations endpoint + * @param {object} data - Contains token and userId from setup + * @returns {object} - The HTTP response + */ +export function getRecommendations(data) { + const headers = { + 'Authorization': `Bearer ${data.token}`, + 'Content-Type': 'application/json' + }; + + return http.get( + `${BASE_URL}/users/${data.userId}/recommendations?latitude=40.6401&longitude=22.9444&maxDistance=100`, + { headers } + ); +} + +/** + * Validates a recommendations response + * @param {object} response - The HTTP response to validate + * @param {number} maxDurationMs - Maximum allowed response time in ms + */ +export function validateRecommendationsResponse(response, maxDurationMs) { + check(response, { + 'status is 200': (r) => r.status === 200, + 'response has recommendations': (r) => { + try { + const body = JSON.parse(r.body); + return body.recommendations !== undefined; + } catch { + return false; + } + }, + [`response time < ${maxDurationMs}ms`]: (r) => r.timings.duration < maxDurationMs, + }); +} diff --git a/k6/load-test-recommendations.js b/k6/load-test-recommendations.js index 15f2e5f..be50884 100644 --- a/k6/load-test-recommendations.js +++ b/k6/load-test-recommendations.js @@ -10,19 +10,12 @@ * - System must support at least 10 concurrent users */ -/* global __ENV */ -import http from 'k6/http'; -import { check, sleep } from 'k6'; - -// Configuration -const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; - -// Test user credentials (from in-memory database) -const TEST_USER = { - email: 'user1@example.com', - password: 'password123', - userId: 16180 -}; +import { sleep } from 'k6'; +import { + authenticateTestUser, + getRecommendations, + validateRecommendationsResponse +} from './helpers/recommendations-helper.js'; // Load Test Configuration export const options = { @@ -39,52 +32,13 @@ export const options = { // Setup function - runs once before the test export function setup() { - // Login to get JWT token - const loginRes = http.post( - `${BASE_URL}/auth/login`, - JSON.stringify({ - email: TEST_USER.email, - password: TEST_USER.password - }), - { - headers: { 'Content-Type': 'application/json' } - } - ); - - check(loginRes, { - 'login successful': (r) => r.status === 200, - }); - - const body = JSON.parse(loginRes.body); - return { token: body.data.token, userId: TEST_USER.userId }; + return authenticateTestUser(); } // Main test function export default function (data) { - const headers = { - 'Authorization': `Bearer ${data.token}`, - 'Content-Type': 'application/json' - }; - - // Request recommendations with location parameters - const response = http.get( - `${BASE_URL}/users/${data.userId}/recommendations?latitude=40.6401&longitude=22.9444&maxDistance=100`, - { headers } - ); - - // Validate response - check(response, { - 'status is 200': (r) => r.status === 200, - 'response has recommendations': (r) => { - try { - const body = JSON.parse(r.body); - return body.recommendations !== undefined; - } catch { - return false; - } - }, - 'response time < 500ms': (r) => r.timings.duration < 500, - }); + const response = getRecommendations(data); + validateRecommendationsResponse(response, 500); // Small pause between requests sleep(1); diff --git a/k6/spike-test-recommendations.js b/k6/spike-test-recommendations.js index d571b69..63a57a3 100644 --- a/k6/spike-test-recommendations.js +++ b/k6/spike-test-recommendations.js @@ -10,19 +10,12 @@ * - Error rate must be less than 5% during spike */ -/* global __ENV */ -import http from 'k6/http'; -import { check, sleep } from 'k6'; - -// Configuration -const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; - -// Test user credentials (from in-memory database) -const TEST_USER = { - email: 'user1@example.com', - password: 'password123', - userId: 16180 -}; +import { sleep } from 'k6'; +import { + authenticateTestUser, + getRecommendations, + validateRecommendationsResponse +} from './helpers/recommendations-helper.js'; // Spike Test Configuration export const options = { @@ -41,52 +34,13 @@ export const options = { // Setup function - runs once before the test export function setup() { - // Login to get JWT token - const loginRes = http.post( - `${BASE_URL}/auth/login`, - JSON.stringify({ - email: TEST_USER.email, - password: TEST_USER.password - }), - { - headers: { 'Content-Type': 'application/json' } - } - ); - - check(loginRes, { - 'login successful': (r) => r.status === 200, - }); - - const body = JSON.parse(loginRes.body); - return { token: body.data.token, userId: TEST_USER.userId }; + return authenticateTestUser(); } // Main test function export default function (data) { - const headers = { - 'Authorization': `Bearer ${data.token}`, - 'Content-Type': 'application/json' - }; - - // Request recommendations with location parameters - const response = http.get( - `${BASE_URL}/users/${data.userId}/recommendations?latitude=40.6401&longitude=22.9444&maxDistance=100`, - { headers } - ); - - // Validate response - check(response, { - 'status is 200': (r) => r.status === 200, - 'response has recommendations': (r) => { - try { - const body = JSON.parse(r.body); - return body.recommendations !== undefined; - } catch { - return false; - } - }, - 'response time < 1000ms': (r) => r.timings.duration < 1000, - }); + const response = getRecommendations(data); + validateRecommendationsResponse(response, 1000); // Minimal pause during spike sleep(0.5); diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js index 732f105..a398f4d 100644 --- a/middleware/errorHandler.js +++ b/middleware/errorHandler.js @@ -130,7 +130,7 @@ const handleValidationError = (err, res) => { * @param {Object} res - Express response object * @returns {Object} JSON response with 401 Unauthorized status */ -const handleInvalidTokenError = (res) => { +const handleInvalidTokenError = (_, res) => { return res.status(401).json( buildErrorResponse( 'INVALID_TOKEN', @@ -149,7 +149,7 @@ const handleInvalidTokenError = (res) => { * @param {Object} res - Express response object * @returns {Object} JSON response with 401 Unauthorized status */ -const handleExpiredTokenError = (res) => { +const handleExpiredTokenError = (_, res) => { return res.status(401).json( buildErrorResponse( 'TOKEN_EXPIRED', @@ -208,15 +208,45 @@ const handleUnknownError = (err, res) => { // Main Error Handler Middleware // ============================================================================= +// ============================================================================= +// Error Type Detection - Maps error characteristics to handler types +// ============================================================================= + +/** + * Error type handlers lookup map. + * Maps error type identifiers to their corresponding handler functions. + */ +const errorTypeHandlers = new Map([ + ['APIError', handleAPIError], + ['DuplicateKey', handleDuplicateKeyError], + ['ValidationError', handleValidationError], + ['JsonWebTokenError', handleInvalidTokenError], + ['TokenExpiredError', handleExpiredTokenError], + ['LegacyAPIError', handleLegacyAPIError], +]); + +/** + * Determine the error type identifier for lookup. + * + * @param {Error} err - The error object to classify + * @returns {string} Error type identifier for handler lookup + */ +const getErrorType = (err) => { + if (err instanceof APIError) return 'APIError'; + if (err.code === 11000) return 'DuplicateKey'; + if (err.statusCode) return 'LegacyAPIError'; + return err.name || 'Unknown'; +}; + /** * Express error handling middleware. * * @description * Main error handler that catches all errors from route handlers and - * middleware. It identifies the error type and delegates to the appropriate - * handler function for standardized response generation. + * middleware. It identifies the error type using a lookup map and delegates + * to the appropriate handler function for standardized response generation. * - * Error handling order: + * Error handling order (via lookup map): * 1. Custom APIError instances * 2. MongoDB duplicate key errors (code 11000) * 3. Mongoose validation errors @@ -240,33 +270,11 @@ const errorHandler = (err, _, res, __) => { // Log error for server-side debugging console.error('Error:', err); - // Route to appropriate handler based on error type - if (err instanceof APIError) { - return handleAPIError(err, res); - } - - if (err.code === 11000) { - return handleDuplicateKeyError(err, res); - } - - if (err.name === 'ValidationError') { - return handleValidationError(err, res); - } - - if (err.name === 'JsonWebTokenError') { - return handleInvalidTokenError(res); - } - - if (err.name === 'TokenExpiredError') { - return handleExpiredTokenError(res); - } - - if (err.statusCode) { - return handleLegacyAPIError(err, res); - } + // Lookup handler by error type, fallback to unknown error handler + const errorType = getErrorType(err); + const handler = errorTypeHandlers.get(errorType) || handleUnknownError; - // Fallback for any unrecognized error types - return handleUnknownError(err, res); + return handler(err, res); }; export default errorHandler; diff --git a/middleware/index.js b/middleware/index.js new file mode 100644 index 0000000..b1e4442 --- /dev/null +++ b/middleware/index.js @@ -0,0 +1,14 @@ +/** + * Middleware Barrier File + * Centralizes middleware exports for cleaner imports + */ + +import errorHandler from './errorHandler.js'; +import { requestLogger } from './logger.js'; +import requestId from './requestId.js'; + +export { + errorHandler, + requestLogger, + requestId +}; diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js index 88ac334..0c03644 100644 --- a/middleware/rateLimiter.js +++ b/middleware/rateLimiter.js @@ -1,25 +1,43 @@ -/** Rate Limiting Middleware */ +/** + * Rate Limiting Middleware + * @description Configurable rate limiters to protect API endpoints + */ import rateLimit from 'express-rate-limit'; -/** Authentication rate limiter */ -export const authLimiter = rateLimit({ - windowMs: 900000, +// --- Time Constants --- +const FIFTEEN_MINUTES = 15 * 60 * 1000; +const ONE_MINUTE = 60 * 1000; + +/** + * Factory to create a consistent rate limiter + * @param {Object} options - Limiter configuration + * @returns {Function} Express middleware + */ +const createLimiter = ({ windowMs, max, messageText, ...extraOptions }) => { + return rateLimit({ + windowMs, + max, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: 'TOO_MANY_REQUESTS', message: messageText }, + ...extraOptions + }); +}; + +/** Authentication rate limiter (15 min window, skips successful) */ +export const authLimiter = createLimiter({ + windowMs: FIFTEEN_MINUTES, max: 10, - standardHeaders: true, - legacyHeaders: false, - skipSuccessfulRequests: true, - message: { success: false, error: 'TOO_MANY_REQUESTS', message: 'Too many auth attempts' } + messageText: 'Too many auth attempts', + skipSuccessfulRequests: true }); -/** API rate limiter */ -export const apiLimiter = rateLimit({ - windowMs: 60000, +/** API rate limiter (1 min window) */ +export const apiLimiter = createLimiter({ + windowMs: ONE_MINUTE, max: 100, - standardHeaders: true, - legacyHeaders: false, - message: { success: false, error: 'TOO_MANY_REQUESTS', message: 'Too many requests' } + messageText: 'Too many requests' }); export default { authLimiter, apiLimiter }; - diff --git a/models/coreModels.js b/models/coreModels.js new file mode 100644 index 0000000..2552301 --- /dev/null +++ b/models/coreModels.js @@ -0,0 +1,8 @@ +/** + * Core Models - User, Place, Counter + * Fundamental entities for the application + */ + +export { default as User } from './User.js'; +export { default as Place } from './Place.js'; +export { default as Counter } from './Counter.js'; diff --git a/models/index.js b/models/index.js index cbe2296..c9407fa 100644 --- a/models/index.js +++ b/models/index.js @@ -3,15 +3,12 @@ * Central export point for all Mongoose models */ -import User from './User.js'; -import Place from './Place.js'; -import PreferenceProfile from './PreferenceProfile.js'; -import Review from './Review.js'; -import Report from './Report.js'; -import FavouritePlace from './FavouritePlace.js'; -import DislikedPlace from './DislikedPlace.js'; -import Settings from './Settings.js'; -import Counter from './Counter.js'; +// Core entities +import { User, Place, Counter } from './coreModels.js'; +// User interactions +import { Review, Report, PreferenceProfile, Settings } from './userInteractionModels.js'; +// Place interactions +import { FavouritePlace, DislikedPlace } from './placeInteractionModels.js'; export default { User, diff --git a/models/placeInteractionModels.js b/models/placeInteractionModels.js new file mode 100644 index 0000000..8054207 --- /dev/null +++ b/models/placeInteractionModels.js @@ -0,0 +1,7 @@ +/** + * Place Interaction Models - Favourites and Dislikes + * Models for user-place relationships + */ + +export { default as FavouritePlace } from './FavouritePlace.js'; +export { default as DislikedPlace } from './DislikedPlace.js'; diff --git a/models/userInteractionModels.js b/models/userInteractionModels.js new file mode 100644 index 0000000..2e769fc --- /dev/null +++ b/models/userInteractionModels.js @@ -0,0 +1,9 @@ +/** + * User Interaction Models - Reviews, Reports, Preferences, Settings + * Models for user interactions and configurations + */ + +export { default as Review } from './Review.js'; +export { default as Report } from './Report.js'; +export { default as PreferenceProfile } from './PreferenceProfile.js'; +export { default as Settings } from './Settings.js'; diff --git a/package-lock.json b/package-lock.json index ae760a2..b0325a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5472,9 +5472,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/routes/authAdminRoutes.js b/routes/authAdminRoutes.js new file mode 100644 index 0000000..09ffc47 --- /dev/null +++ b/routes/authAdminRoutes.js @@ -0,0 +1,7 @@ +/** + * Authentication and Admin Routes + * Routes for authentication and administrative functions + */ + +export { default as authRoutes } from './authRoutes.js'; +export { default as adminRoutes } from './adminRoutes.js'; diff --git a/routes/index.js b/routes/index.js index d3d155f..99956a1 100644 --- a/routes/index.js +++ b/routes/index.js @@ -28,14 +28,12 @@ */ import express from 'express'; -import authRoutes from './authRoutes.js'; -import userRoutes from './userRoutes.js'; -import preferenceRoutes from './preferenceRoutes.js'; -import recommendationRoutes from './recommendationRoutes.js'; -import placeRoutes from './placeRoutes.js'; -import favouriteRoutes from './favouriteRoutes.js'; -import navigationRoutes from './navigationRoutes.js'; -import adminRoutes from './adminRoutes.js'; +// Authentication and admin routes +import { authRoutes, adminRoutes } from './authAdminRoutes.js'; +// User-related routes +import { userRoutes, preferenceRoutes, recommendationRoutes, favouriteRoutes } from './userRelatedRoutes.js'; +// Place and navigation routes +import { placeRoutes, navigationRoutes } from './placeNavigationRoutes.js'; const router = express.Router(); diff --git a/routes/placeNavigationRoutes.js b/routes/placeNavigationRoutes.js new file mode 100644 index 0000000..44d5f50 --- /dev/null +++ b/routes/placeNavigationRoutes.js @@ -0,0 +1,7 @@ +/** + * Place and Navigation Routes + * Routes for place information and navigation services + */ + +export { default as placeRoutes } from './placeRoutes.js'; +export { default as navigationRoutes } from './navigationRoutes.js'; diff --git a/routes/userRelatedRoutes.js b/routes/userRelatedRoutes.js new file mode 100644 index 0000000..710cd0a --- /dev/null +++ b/routes/userRelatedRoutes.js @@ -0,0 +1,9 @@ +/** + * User-Related Routes + * Routes for user profiles, preferences, recommendations, and favourites + */ + +export { default as userRoutes } from './userRoutes.js'; +export { default as preferenceRoutes } from './preferenceRoutes.js'; +export { default as recommendationRoutes } from './recommendationRoutes.js'; +export { default as favouriteRoutes } from './favouriteRoutes.js'; diff --git a/services/authService.js b/services/authService.js index 3856271..a900e7b 100644 --- a/services/authService.js +++ b/services/authService.js @@ -6,53 +6,91 @@ import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import db from '../config/db.js'; -import { ValidationError, AuthenticationError, ConflictError } from '../utils/errors.js'; -import { isValidEmail, validatePassword } from '../utils/validators.js'; -import { sanitizeUser } from '../utils/helpers.js'; +import { + ValidationError, + AuthenticationError, + ConflictError, + isValidEmail, + validatePassword, + sanitizeUser +} from '../utils/index.js'; import { JWT_EXPIRES_IN } from '../config/constants.js'; +// ============================================================================= +// Validation Helpers - Reduce complexity by extracting validation logic +// ============================================================================= + /** - * Authenticate user with email and password + * Validate login input fields * @param {string} email - User email * @param {string} password - User password - * @returns {Promise} Object containing token and user data + * @throws {ValidationError} If validation fails */ -export const loginUser = async (email, password) => { - // Validate input +const validateLoginInput = (email, password) => { if (!email || !password) { throw new ValidationError('Email and password are required'); } - if (!isValidEmail(email)) { throw new ValidationError('Invalid email format'); } +}; - // Find user - const user = await db.findUserByEmail(email); - if (!user) { - throw new AuthenticationError('Invalid email or password'); +/** + * Validate registration input fields + * @param {Object} userData - Registration data + * @throws {ValidationError} If validation fails + */ +const validateRegistrationInput = ({ name, email, password }) => { + if (!name || !email || !password) { + throw new ValidationError('All fields are required'); } + if (!isValidEmail(email)) { + throw new ValidationError('Invalid email format', 'email'); + } + const passwordValidation = validatePassword(password); + if (!passwordValidation.isValid) { + throw new ValidationError(passwordValidation.errors[0], 'password'); + } +}; + +/** + * Assert password is valid + * @param {string} password - Password to validate + * @param {string} field - Field name for error context + * @throws {ValidationError} If password is invalid + */ +const assertPasswordValid = (password, field = 'password') => { + const passwordValidation = validatePassword(password); + if (!passwordValidation.isValid) { + throw new ValidationError(passwordValidation.errors[0], field); + } +}; + +// ============================================================================= +// Authentication Functions +// ============================================================================= - // Check if user has a password - if (!user.password) { - console.error(`User ${email} found but has no password field`); +/** + * Authenticate user with email and password + * @param {string} email - User email + * @param {string} password - User password + * @returns {Promise} Object containing token and user data + */ +export const loginUser = async (email, password) => { + validateLoginInput(email, password); + + const user = await db.findUserByEmail(email); + if (!user?.password) { + if (user) console.error(`User ${email} found but has no password field`); throw new AuthenticationError('Invalid email or password'); } - // Verify password const isValidPassword = await bcrypt.compare(password, user.password); if (!isValidPassword) { throw new AuthenticationError('Invalid email or password'); } - // Generate JWT token - const token = generateToken(user); - - // Return sanitized user data - return { - token, - user: sanitizeUser(user) - }; + return { token: generateToken(user), user: sanitizeUser(user) }; }; /** @@ -61,55 +99,24 @@ export const loginUser = async (email, password) => { * @returns {Promise} Object containing token and user data */ export const registerUser = async ({ name, email, password }) => { - // Validate required fields - if (!name || !email || !password) { - throw new ValidationError('All fields are required'); - } + validateRegistrationInput({ name, email, password }); - // Validate email format - if (!isValidEmail(email)) { - throw new ValidationError('Invalid email format', 'email'); - } - - // Validate password - const passwordValidation = validatePassword(password); - if (!passwordValidation.isValid) { - throw new ValidationError(passwordValidation.errors[0], 'password'); - } - - // Check if user already exists const existingUser = await db.findUserByEmail(email); if (existingUser) { throw new ConflictError('User with this email already exists', 'email'); } - // Hash password const hashedPassword = await bcrypt.hash(password, 10); - - // Create new user const newUser = await db.createUser({ name: name.trim(), email: email.trim().toLowerCase(), password: hashedPassword, role: 'user', - preferences: { - categories: [], - tags: [] - }, - location: { - latitude: null, - longitude: null - } + preferences: { categories: [], tags: [] }, + location: { latitude: null, longitude: null } }); - // Generate JWT token - const token = generateToken(newUser); - - // Return sanitized user data - return { - token, - user: sanitizeUser(newUser) - }; + return { token: generateToken(newUser), user: sanitizeUser(newUser) }; }; /** @@ -119,10 +126,10 @@ export const registerUser = async ({ name, email, password }) => { */ const generateToken = (user) => { return jwt.sign( - { - userId: user.userId, - email: user.email, - role: user.role + { + userId: user.userId, + email: user.email, + role: user.role }, process.env.JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } @@ -153,28 +160,19 @@ export const verifyToken = (token) => { * @returns {Promise} True if successful */ export const changePassword = async (userId, oldPassword, newPassword) => { - // Find user const user = await db.findUserById(userId); if (!user) { throw new AuthenticationError('User not found'); } - // Verify old password const isValid = await bcrypt.compare(oldPassword, user.password); if (!isValid) { throw new AuthenticationError('Current password is incorrect'); } - // Validate new password - const passwordValidation = validatePassword(newPassword); - if (!passwordValidation.isValid) { - throw new ValidationError(passwordValidation.errors[0], 'newPassword'); - } + assertPasswordValid(newPassword, 'newPassword'); - // Hash new password const hashedPassword = await bcrypt.hash(newPassword, 10); - - // Update password await db.updateUserById(userId, { password: hashedPassword }); return true; diff --git a/services/placeService.js b/services/placeService.js index b81b13f..92348ff 100644 --- a/services/placeService.js +++ b/services/placeService.js @@ -13,26 +13,32 @@ import db from '../config/db.js'; import { ValidationError, NotFoundError } from '../utils/errors.js'; import { isValidPlaceId } from '../utils/validators.js'; +// --- Helper Functions (Private) --- + /** - * Gets a place by its ID. + * Validates place ID and retrieves the place, or throws appropriate error. * @param {string|number} placeId - The place's ID * @returns {Promise} Place object * @throws {ValidationError} If placeId is invalid * @throws {NotFoundError} If place not found */ -export const getPlaceById = async (placeId) => { +const _findPlaceOrThrow = async (placeId) => { if (!isValidPlaceId(placeId)) { throw new ValidationError('Invalid place ID'); } - const place = await db.findPlaceById(placeId); if (!place) { throw new NotFoundError('Place', placeId); } - return place; }; +// --- Service Methods --- + +export const getPlaceById = async (placeId) => { + return await _findPlaceOrThrow(placeId); +}; + export const searchPlaces = async (searchParams) => { return await db.searchPlaces(searchParams); }; @@ -46,28 +52,12 @@ export const createPlace = async (placeData) => { }; export const updatePlace = async (placeId, updateData) => { - if (!isValidPlaceId(placeId)) { - throw new ValidationError('Invalid place ID'); - } - - const place = await db.findPlaceById(placeId); - if (!place) { - throw new NotFoundError('Place', placeId); - } - + await _findPlaceOrThrow(placeId); return await db.updatePlace(placeId, updateData); }; export const deletePlace = async (placeId) => { - if (!isValidPlaceId(placeId)) { - throw new ValidationError('Invalid place ID'); - } - - const place = await db.findPlaceById(placeId); - if (!place) { - throw new NotFoundError('Place', placeId); - } - + await _findPlaceOrThrow(placeId); return await db.deletePlace(placeId); }; @@ -79,3 +69,4 @@ export default { updatePlace, deletePlace }; + diff --git a/services/recommendationService.js b/services/recommendationService.js new file mode 100644 index 0000000..37c06fa --- /dev/null +++ b/services/recommendationService.js @@ -0,0 +1,144 @@ +/** + * Recommendation Service + * Handles business logic for generating place recommendations + */ + +import db from '../config/db.js'; +import buildHateoasLinks from '../utils/hateoasBuilder.js'; +import { calculateDistance } from '../utils/geoUtils.js'; + +// --- Helper Functions (Private) --- + +/** Determine the active profile for the user */ +const resolveActiveProfile = (profiles, userObj) => { + if (!profiles || profiles.length === 0) return null; + // Use active profile, or fallback to the most recent one + return profiles.find(p => p.profileId === userObj.activeProfile) || profiles[profiles.length - 1]; +}; + +/** Filter places based on categories and remove disliked ones */ +const filterPlaces = (allPlaces, dislikedIds) => { + return allPlaces.filter(place => { + const placeObj = place.toObject ? place.toObject() : place; + return !dislikedIds.includes(placeObj.placeId); + }); +}; + +// --- Sorting Helpers --- + +/** + * Partition places into those with and without valid location data + */ +const partitionByLocation = (places, userLat, userLon) => { + const withLoc = []; + const withoutLoc = []; + + places.forEach(place => { + if (place.location?.latitude && place.location?.longitude) { + withLoc.push({ + ...place, + distance: calculateDistance( + { latitude: userLat, longitude: userLon }, + { latitude: place.location.latitude, longitude: place.location.longitude } + ) + }); + } else { + withoutLoc.push(place); + } + }); + + return { withLoc, withoutLoc }; +}; + +/** Sort places by rating (descending) */ +const sortByRating = (places) => [...places].sort((a, b) => (b.rating || 0) - (a.rating || 0)); + +/** Sort places by distance (ascending) */ +const sortByDistance = (places) => [...places].sort((a, b) => a.distance - b.distance); + +/** Sort places by distance (if coords provided) or rating */ +const sortPlaces = (places, { latitude, longitude, maxDistance }) => { + const placeList = places.map(p => p.toObject ? p.toObject() : p); + + if (!latitude || !longitude) { + return sortByRating(placeList); + } + + const userLat = parseFloat(latitude); + const userLon = parseFloat(longitude); + const maxDist = maxDistance ? parseFloat(maxDistance) : null; + + // Calculate distances and separate places with/without location + const { withLoc, withoutLoc } = partitionByLocation(placeList, userLat, userLon); + + // Sort by distance, filter by maxDist, then append places without location (sorted by rating) + const sortedWithLoc = sortByDistance(withLoc); + const filteredWithLoc = maxDist ? sortedWithLoc.filter(p => p.distance <= maxDist) : sortedWithLoc; + const sortedWithoutLoc = sortByRating(withoutLoc); + + return [...filteredWithLoc, ...sortedWithoutLoc]; +}; + +/** Fetch reviews and build links for the final list */ +const hydrateRecommendations = async (places) => { + return Promise.all(places.map(async (place) => { + const reviews = await db.getReviewsForPlace(place.placeId); + return { + ...place, + reviews, + links: buildHateoasLinks.selectLink(place.placeId) + }; + })); +}; + +// --- Main Service Methods --- + +/** + * Clean up the profile object + */ +const getProfileData = (profile) => { + return profile.toObject ? profile.toObject() : profile; +}; + +/** + * Core logic to generate recommendations + */ +const generateRecommendations = async (userId, user, queryParams) => { + const userObj = user.toObject ? user.toObject() : user; + const profiles = await db.getPreferenceProfiles(userId); + const activeProfile = resolveActiveProfile(profiles, userObj); + + if (!activeProfile) { + return { + recommendations: [], + activeProfile: null, + message: 'Create a preference profile to see recommendations' + }; + } + + // 1. Gather Data + const profileObj = getProfileData(activeProfile); + const dislikedPlaces = await db.getDislikedPlaces(userId); + const categories = profileObj.categories || profileObj.selectedPreferences || []; + + // 2. Fetch & Filter + const rawPlaces = await db.getPlacesByCategories(categories); + const filteredPlaces = filterPlaces(rawPlaces, dislikedPlaces.map(d => d.placeId)); + + // 3. Sort & Limit + const sortedPlaces = sortPlaces(filteredPlaces, queryParams); + const topPlaces = sortedPlaces.slice(0, 10); + + // 4. Hydrate (Reviews/Links) + const finalRecommendations = await hydrateRecommendations(topPlaces); + + return { + recommendations: finalRecommendations, + activeProfile: profileObj.name || profileObj.profileName, + message: 'Recommendations generated successfully' + }; +}; + +export default { + generateRecommendations +}; diff --git a/services/userService.js b/services/userService.js index ea58183..5a083cd 100644 --- a/services/userService.js +++ b/services/userService.js @@ -91,6 +91,26 @@ export const getUserSettings = async (userId) => { * @param {Object} settingsData - Settings to update * @returns {Promise} Updated settings */ + +// Allowed settings fields (supports both frontend and backend field names) +const SETTINGS_FIELDS = [ + 'preferredLanguage', 'language', 'emailNotifications', + 'pushNotifications', 'accessibilitySettings', 'privacySettings', + 'userAgreementAccepted' +]; + +/** + * Build updates object from settings data using field whitelist + * @param {Object} settingsData - Settings data from request + * @returns {Object} Filtered updates object + */ +const buildSettingsUpdates = (settingsData) => + Object.fromEntries( + SETTINGS_FIELDS + .filter(field => settingsData[field] !== undefined) + .map(field => [field, settingsData[field]]) + ); + export const updateUserSettings = async (userId, settingsData) => { if (!isValidUserId(userId)) { throw new ValidationError('Invalid user ID'); @@ -102,30 +122,8 @@ export const updateUserSettings = async (userId, settingsData) => { throw new NotFoundError('User', userId); } - // Prepare updates (support both frontend and backend field names) - const updates = {}; - - if (settingsData.preferredLanguage) { - updates.preferredLanguage = settingsData.preferredLanguage; - } - if (settingsData.language) { - updates.language = settingsData.language; - } - if (settingsData.emailNotifications !== undefined) { - updates.emailNotifications = settingsData.emailNotifications; - } - if (settingsData.pushNotifications !== undefined) { - updates.pushNotifications = settingsData.pushNotifications; - } - if (settingsData.accessibilitySettings) { - updates.accessibilitySettings = settingsData.accessibilitySettings; - } - if (settingsData.privacySettings) { - updates.privacySettings = settingsData.privacySettings; - } - if (settingsData.userAgreementAccepted !== undefined) { - updates.userAgreementAccepted = settingsData.userAgreementAccepted; - } + // Build updates using field whitelist pattern + const updates = buildSettingsUpdates(settingsData); return await db.updateSettings(userId, updates); }; diff --git a/tests/integration/recommendation.happy.test.js b/tests/integration/recommendation.happy.test.js index bce87a1..8b55d3a 100644 --- a/tests/integration/recommendation.happy.test.js +++ b/tests/integration/recommendation.happy.test.js @@ -11,96 +11,120 @@ import { createAuthenticatedUser, authRequest } from '../helpers/testUtils.js'; import db from '../../config/db.js'; -/** - * Recommendation Controller - Happy Path Tests - * @description Tests for successful recommendation retrieval. - */ +// --- Test Helpers --- + +/** Setup user with a preference profile */ +const setupUserWithProfile = async (profileName, categories) => { + const { user, token } = await createAuthenticatedUser(); + await db.addPreferenceProfile({ userId: user.userId, name: profileName, categories }); + return { user, token }; +}; + +/** Create a test place with sensible defaults */ +const createTestPlace = (overrides = {}) => db.createPlace({ + name: 'Test Place', + category: 'MUSEUM', + description: 'Test description', + city: 'Athens', + rating: 4.5, + ...overrides +}); + +/** Get recommendation for user */ +const getRecommendations = (token, userId) => authRequest(token).get(`/users/${userId}/recommendations`); + +/** Extract place IDs from response */ +const getPlaceIds = (response) => response.body.data.recommendations.map(r => r.placeId); + +// --- Test Suite --- + describe('Recommendation Controller - Happy Path Tests', () => { describe('GET /users/:userId/recommendations', () => { it('should return 200 and empty array without preference profile', async () => { const { user, token } = await createAuthenticatedUser(); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); + const response = await getRecommendations(token, user.userId); expect(response.status).toBe(200); expect(response.body.data.recommendations).toEqual([]); }); it('should return places matching user preferences', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'Museum Lover', categories: ['MUSEUM'] }); - const museumPlace = await db.createPlace({ name: 'Test Museum', category: 'MUSEUM', description: 'Ancient', city: 'Athens', rating: 4.8 }); - await db.createPlace({ name: 'Paradise Beach', category: 'BEACH', description: 'Beach', city: 'Mykonos', rating: 4.5 }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); + const { user, token } = await setupUserWithProfile('Museum Lover', ['MUSEUM']); + const museum = await createTestPlace({ name: 'Test Museum', category: 'MUSEUM' }); + await createTestPlace({ name: 'Paradise Beach', category: 'BEACH' }); + + const response = await getRecommendations(token, user.userId); expect(response.status).toBe(200); - const placeIds = response.body.data.recommendations.map(r => r.placeId); - expect(placeIds).toContain(museumPlace.placeId); + expect(getPlaceIds(response)).toContain(museum.placeId); }); it('should filter multiple categories', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'Night & Shopping', categories: ['NIGHTLIFE', 'SHOPPING'] }); - const nightclub = await db.createPlace({ name: 'Athens Nightclub', category: 'NIGHTLIFE', description: 'Popular', city: 'Athens', rating: 4.7 }); - const mall = await db.createPlace({ name: 'Shopping Mall', category: 'SHOPPING', description: 'Large', city: 'Athens', rating: 4.6 }); - await db.createPlace({ name: 'Olympic Stadium', category: 'SPORTS', description: 'Sports', city: 'Athens', rating: 4.3 }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); - const placeIds = response.body.data.recommendations.map(r => r.placeId); + const { user, token } = await setupUserWithProfile('Night & Shopping', ['NIGHTLIFE', 'SHOPPING']); + const nightclub = await createTestPlace({ name: 'Athens Nightclub', category: 'NIGHTLIFE', rating: 4.7 }); + const mall = await createTestPlace({ name: 'Shopping Mall', category: 'SHOPPING', rating: 4.6 }); + await createTestPlace({ name: 'Olympic Stadium', category: 'SPORTS' }); + + const placeIds = getPlaceIds(await getRecommendations(token, user.userId)); expect(placeIds).toContain(nightclub.placeId); expect(placeIds).toContain(mall.placeId); }); it('should exclude disliked places', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'Park Profile', categories: ['PARK'] }); - const park1 = await db.createPlace({ name: 'Central Park', category: 'PARK', description: 'Central', city: 'Athens', rating: 4.8 }); - const park2 = await db.createPlace({ name: 'National Garden', category: 'PARK', description: 'Garden', city: 'Athens', rating: 4.5 }); + const { user, token } = await setupUserWithProfile('Park Profile', ['PARK']); + const park1 = await createTestPlace({ name: 'Central Park', category: 'PARK', rating: 4.8 }); + const park2 = await createTestPlace({ name: 'National Garden', category: 'PARK', rating: 4.5 }); await db.addDislikedPlace(user.userId, park1.placeId); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); - const placeIds = response.body.data.recommendations.map(r => r.placeId); + + const placeIds = getPlaceIds(await getRecommendations(token, user.userId)); expect(placeIds).toContain(park2.placeId); expect(placeIds).not.toContain(park1.placeId); }); it('should sort places by rating', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'Beach Lover', categories: ['BEACH'] }); - await db.createPlace({ name: 'Low Beach', category: 'BEACH', description: 'Beach', city: 'Athens', rating: 3.5 }); - await db.createPlace({ name: 'High Beach', category: 'BEACH', description: 'Best', city: 'Athens', rating: 4.9 }); - await db.createPlace({ name: 'Med Beach', category: 'BEACH', description: 'Nice', city: 'Athens', rating: 4.2 }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); - const ratings = response.body.data.recommendations.map(r => r.rating); + const { user, token } = await setupUserWithProfile('Beach Lover', ['BEACH']); + await createTestPlace({ name: 'Low Beach', category: 'BEACH', rating: 3.5 }); + await createTestPlace({ name: 'High Beach', category: 'BEACH', rating: 4.9 }); + await createTestPlace({ name: 'Med Beach', category: 'BEACH', rating: 4.2 }); + + const ratings = (await getRecommendations(token, user.userId)).body.data.recommendations.map(r => r.rating); expect(ratings[0]).toBeGreaterThanOrEqual(ratings[1]); }); it('should return up to 10 recommendations', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'Park Lover', categories: ['PARK'] }); - for (let i = 1; i <= 15; i++) await db.createPlace({ name: `Park ${i}`, category: 'PARK', description: `Park ${i}`, city: 'Athens', rating: 4.0 + (i * 0.01) }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); + const { user, token } = await setupUserWithProfile('Park Lover', ['PARK']); + + // Batch creation with Promise.all for better performance + const promises = Array.from({ length: 15 }, (_, i) => + createTestPlace({ name: `Park ${i}`, category: 'PARK', rating: 4.0 + (i * 0.01) }) + ); + await Promise.all(promises); + + const response = await getRecommendations(token, user.userId); expect(response.body.data.recommendations.length).toBeLessThanOrEqual(10); }); it('should contain reviews for each place', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'Culture', categories: ['CULTURE'] }); - const place = await db.createPlace({ name: 'Cultural Center', category: 'CULTURE', description: 'Arts', city: 'Athens', rating: 4.5 }); + const { user, token } = await setupUserWithProfile('Culture', ['CULTURE']); + const place = await createTestPlace({ name: 'Cultural Center', category: 'CULTURE' }); await db.addReview({ userId: user.userId, placeId: place.placeId, rating: 5, comment: 'Great!' }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); + + const response = await getRecommendations(token, user.userId); const placeWithReview = response.body.data.recommendations.find(p => p.placeId === place.placeId); expect(placeWithReview.reviews).toHaveLength(1); }); it('should contain HATEOAS links', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'Test', categories: ['MUSEUM'] }); - await db.createPlace({ name: 'Test Museum', category: 'MUSEUM', description: 'Test', city: 'Athens', rating: 4.5 }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); + const { user, token } = await setupUserWithProfile('Test', ['MUSEUM']); + await createTestPlace({ name: 'Test Museum', category: 'MUSEUM' }); + + const response = await getRecommendations(token, user.userId); expect(response.body.data).toHaveProperty('links'); }); it('should return activeProfile name', async () => { - const { user, token } = await createAuthenticatedUser(); - await db.addPreferenceProfile({ userId: user.userId, name: 'My Active Profile', categories: ['NIGHTLIFE'] }); - await db.createPlace({ name: 'Test Club', category: 'NIGHTLIFE', description: 'Test', city: 'Athens', rating: 4.5 }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); + const { user, token } = await setupUserWithProfile('My Active Profile', ['NIGHTLIFE']); + await createTestPlace({ name: 'Test Club', category: 'NIGHTLIFE' }); + + const response = await getRecommendations(token, user.userId); expect(response.body.data.activeProfile).toBe('My Active Profile'); }); @@ -108,9 +132,11 @@ describe('Recommendation Controller - Happy Path Tests', () => { const { user, token } = await createAuthenticatedUser(); await db.addPreferenceProfile({ userId: user.userId, name: 'Old Profile', categories: ['BEACH'] }); await db.addPreferenceProfile({ userId: user.userId, name: 'Recent Profile', categories: ['SPORTS'] }); - await db.createPlace({ name: 'Test Stadium', category: 'SPORTS', description: 'Test', city: 'Athens', rating: 4.5 }); - const response = await authRequest(token).get(`/users/${user.userId}/recommendations`); + await createTestPlace({ name: 'Test Stadium', category: 'SPORTS' }); + + const response = await getRecommendations(token, user.userId); expect(response.body.data.activeProfile).toBe('Recent Profile'); }); }); }); + diff --git a/tests/unit/hateoas.userLinks.test.js b/tests/unit/hateoas.userLinks.test.js index 41544f5..f7d95bf 100644 --- a/tests/unit/hateoas.userLinks.test.js +++ b/tests/unit/hateoas.userLinks.test.js @@ -1,84 +1,166 @@ +/** + * Test suite for HATEOAS user links generation + * Validates that hypermedia links are correctly generated for user-related resources + */ import userLinks from '../../utils/hateoas/userLinks.js'; describe('userLinks', () => { + // Sample IDs for testing link generation across different resource types const userId = 'user123'; const profileId = 'profile456'; const favouriteId = 'fav789'; const placeId = 'place321'; + /** + * Test user profile links generation + * Verifies all navigational links from the user profile endpoint + */ test('userProfile links', () => { + // Execute the userProfile link builder with test user ID expect(userLinks.userProfile(userId)).toEqual({ + // Self-reference link for retrieving user profile self: { href: `/users/${userId}/profile`, method: 'GET' }, + // Link to update profile information edit: { href: `/users/${userId}/profile`, method: 'PUT' }, + // Navigation to user settings settings: { href: `/users/${userId}/settings`, method: 'GET' }, + // Access to user's preference profiles collection 'preference-profiles': { href: `/users/${userId}/preference-profiles`, method: 'GET' }, + // Action to create new preference profile 'create-profile': { href: `/users/${userId}/preference-profiles`, method: 'POST' }, + // Templated link to recommendations with optional location parameter recommendations: { href: `/users/${userId}/recommendations{?currentLocation}`, method: 'GET' }, + // Link to user's favourite places collection favourites: { href: `/users/${userId}/favourite-places`, method: 'GET' } }); }); + /** + * Test settings resource links + * Validates links for user settings management + */ test('settings links', () => { + // Execute the settings link builder expect(userLinks.settings(userId)).toEqual({ + // Self-reference for settings retrieval self: { href: `/users/${userId}/settings`, method: 'GET' }, + // Link to update settings update: { href: `/users/${userId}/settings`, method: 'PUT' }, + // Navigation back to user profile 'user-profile': { href: `/users/${userId}/profile`, method: 'GET' } }); }); + /** + * Test preference profiles collection links + * Ensures collection-level operations are correctly linked + */ test('preferenceProfilesCollection links', () => { + // Execute the preference profiles collection link builder expect(userLinks.preferenceProfilesCollection(userId)).toEqual({ + // Self-reference for collection retrieval self: { href: `/users/${userId}/preference-profiles`, method: 'GET' }, + // Action to create new profile in collection 'create-profile': { href: `/users/${userId}/preference-profiles`, method: 'POST' }, + // Navigation to parent user profile 'user-profile': { href: `/users/${userId}/profile`, method: 'GET' } }); }); + /** + * Test individual preference profile links + * Validates CRUD operations and related actions for a specific profile + */ test('preferenceProfile links', () => { + // Execute the individual preference profile link builder expect(userLinks.preferenceProfile(userId, profileId)).toEqual({ + // Self-reference for profile retrieval self: { href: `/users/${userId}/preference-profiles/${profileId}`, method: 'GET' }, + // Link to update this preference profile edit: { href: `/users/${userId}/preference-profiles/${profileId}`, method: 'PUT' }, + // Link to delete this preference profile delete: { href: `/users/${userId}/preference-profiles/${profileId}`, method: 'DELETE' }, + // Action to activate this profile as the active preference activate: { href: `/users/${userId}/profile`, method: 'PUT' }, + // Get recommendations based on this profile recommendations: { href: `/users/${userId}/recommendations{?currentLocation}`, method: 'GET' }, + // Navigation to parent user profile 'user-profile': { href: `/users/${userId}/profile`, method: 'GET' } }); }); + /** + * Test recommendations resource links + * Validates links for recommendation refresh and navigation + */ test('recommendations links', () => { + // Execute the recommendations link builder expect(userLinks.recommendations(userId)).toEqual({ + // Link to refresh recommendations with optional location refresh: { href: `/users/${userId}/recommendations{?currentLocation}`, method: 'GET' }, + // Navigation back to user profile 'user-profile': { href: `/users/${userId}/profile`, method: 'GET' } }); }); + /** + * Test favourites collection links + * Ensures links for managing favourite places are correct + */ test('favouritesCollection links', () => { + // Execute the favourites collection link builder expect(userLinks.favouritesCollection(userId)).toEqual({ + // Self-reference for favourites list retrieval self: { href: `/users/${userId}/favourite-places`, method: 'GET' }, + // Action to add new favourite place 'add-favourite': { href: `/users/${userId}/favourite-places`, method: 'POST' }, + // Navigation to user profile 'user-profile': { href: `/users/${userId}/profile`, method: 'GET' } }); }); + /** + * Test individual favourite resource links + * Validates links for specific favourite place operations + */ test('favourite links', () => { + // Execute the favourite link builder with all required IDs expect(userLinks.favourite(userId, favouriteId, placeId)).toEqual({ + // Self-reference to favourites collection self: { href: `/users/${userId}/favourite-places`, method: 'GET' }, + // Action to remove this favourite remove: { href: `/users/${userId}/favourite-places/${favouriteId}`, method: 'DELETE' }, + // Link to the associated place details place: { href: `/places/${placeId}`, method: 'GET' }, + // Navigation to user profile 'user-profile': { href: `/users/${userId}/profile`, method: 'GET' } }); }); + /** + * Test favourite item links within a collection + * Validates item-level actions for favourites + */ test('favouriteItem links', () => { + // Execute the favourite item link builder expect(userLinks.favouriteItem(userId, favouriteId, placeId)).toEqual({ + // Link to select and view the place details select: { href: `/places/${placeId}`, method: 'GET' }, + // Action to remove from favourites 'remove-favourite': { href: `/users/${userId}/favourite-places/${favouriteId}`, method: 'DELETE' } }); }); + /** + * Test disliked place links + * Validates navigation after disliking a place + */ test('dislikedPlace links', () => { + // Execute the disliked place link builder expect(userLinks.dislikedPlace(userId)).toEqual({ + // Link to get new recommendations after disliking recommendations: { href: `/users/${userId}/recommendations{?currentLocation}`, method: 'GET' }, + // Navigation back to user profile 'user-profile': { href: `/users/${userId}/profile`, method: 'GET' } }); }); diff --git a/tests/unit/models.DislikedPlace.test.js b/tests/unit/models.DislikedPlace.test.js index 39544a5..99afde7 100644 --- a/tests/unit/models.DislikedPlace.test.js +++ b/tests/unit/models.DislikedPlace.test.js @@ -11,19 +11,23 @@ import { } from '../helpers/mongoDbSetup.js'; describe('DislikedPlace Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); describe('Static Methods', () => { + // Create test disliked places for different users beforeEach(async () => { await DislikedPlace.create({ dislikedId: 1, userId: 100, placeId: 200 }); await DislikedPlace.create({ dislikedId: 2, userId: 100, placeId: 201 }); diff --git a/tests/unit/models.FavouritePlace.test.js b/tests/unit/models.FavouritePlace.test.js index e077def..40a57bd 100644 --- a/tests/unit/models.FavouritePlace.test.js +++ b/tests/unit/models.FavouritePlace.test.js @@ -11,19 +11,23 @@ import { } from '../helpers/mongoDbSetup.js'; describe('FavouritePlace Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); describe('Static Methods', () => { + // Create test favourite places for different users beforeEach(async () => { await FavouritePlace.create({ favouriteId: 1, userId: 100, placeId: 200 }); await FavouritePlace.create({ favouriteId: 2, userId: 100, placeId: 201 }); diff --git a/tests/unit/models.Place.test.js b/tests/unit/models.Place.test.js index a49f5e2..faa58f6 100644 --- a/tests/unit/models.Place.test.js +++ b/tests/unit/models.Place.test.js @@ -11,19 +11,23 @@ import { } from '../helpers/mongoDbSetup.js'; describe('Place Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); describe('Static Methods', () => { + // Create test places with varied categories and locations beforeEach(async () => { await Place.create({ placeId: 1, name: 'Coffee Shop', category: 'Food', @@ -97,6 +101,7 @@ describe('Place Model', () => { expect(place.hasValidLocation()).toBe(false); }); + // Test full address concatenation with all fields test('getFullAddress joins address parts', async () => { const place = await Place.create({ placeId: 20, name: 'Full Address', category: 'Test', @@ -105,6 +110,7 @@ describe('Place Model', () => { expect(place.getFullAddress()).toBe('123 Main St, Athens, Greece'); }); + // Test address concatenation with missing fields test('getFullAddress handles missing parts', async () => { const place = await Place.create({ placeId: 21, name: 'Partial Address', category: 'Test', @@ -113,6 +119,7 @@ describe('Place Model', () => { expect(place.getFullAddress()).toBe('Athens'); }); + // Test empty address when no fields are set test('getFullAddress returns empty string when no address fields', async () => { const place = await Place.create({ placeId: 22, name: 'No Address', category: 'Test' diff --git a/tests/unit/models.PreferenceProfile.test.js b/tests/unit/models.PreferenceProfile.test.js index aa8e315..2125ad5 100644 --- a/tests/unit/models.PreferenceProfile.test.js +++ b/tests/unit/models.PreferenceProfile.test.js @@ -11,19 +11,23 @@ import { } from '../helpers/mongoDbSetup.js'; describe('PreferenceProfile Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); describe('Static Methods', () => { + // Create test profiles with active/inactive states beforeEach(async () => { await PreferenceProfile.create({ profileId: 1, userId: 100, name: 'Default', @@ -80,6 +84,7 @@ describe('PreferenceProfile Model', () => { expect(updated.isActive).toBe(true); }); + // Test category membership check test('hasCategory returns true for existing category', async () => { const profile = await PreferenceProfile.create({ profileId: 20, userId: 100, name: 'Test', @@ -88,6 +93,7 @@ describe('PreferenceProfile Model', () => { expect(profile.hasCategory('Food')).toBe(true); }); + // Test category check for non-existent category test('hasCategory returns false for missing category', async () => { const profile = await PreferenceProfile.create({ profileId: 21, userId: 100, name: 'Test', @@ -96,6 +102,7 @@ describe('PreferenceProfile Model', () => { expect(profile.hasCategory('Culture')).toBe(false); }); + // Test tag membership check test('hasTag returns true for existing tag', async () => { const profile = await PreferenceProfile.create({ profileId: 30, userId: 100, name: 'Test', @@ -104,6 +111,7 @@ describe('PreferenceProfile Model', () => { expect(profile.hasTag('outdoor')).toBe(true); }); + // Test tag check for non-existent tag test('hasTag returns false for missing tag', async () => { const profile = await PreferenceProfile.create({ profileId: 31, userId: 100, name: 'Test', diff --git a/tests/unit/models.Report.test.js b/tests/unit/models.Report.test.js index ba2d459..9e17f3d 100644 --- a/tests/unit/models.Report.test.js +++ b/tests/unit/models.Report.test.js @@ -11,19 +11,23 @@ import { } from '../helpers/mongoDbSetup.js'; describe('Report Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); describe('Static Methods', () => { + // Create test reports with different statuses beforeEach(async () => { await Report.create({ reportId: 1, userId: 100, placeId: 200, description: 'Issue 1', status: 'PENDING' }); await Report.create({ reportId: 2, userId: 100, placeId: 201, description: 'Issue 2', status: 'REVIEWED' }); @@ -81,6 +85,7 @@ describe('Report Model', () => { expect(updated.status).toBe('REVIEWED'); }); + // Test pending status check test('isPending returns true for pending report', async () => { const report = await Report.create({ reportId: 20, userId: 100, placeId: 200, description: 'Test' @@ -88,6 +93,7 @@ describe('Report Model', () => { expect(report.isPending()).toBe(true); }); + // Test pending status for resolved report test('isPending returns false for resolved report', async () => { const report = await Report.create({ reportId: 21, userId: 100, placeId: 200, description: 'Test', status: 'RESOLVED' @@ -95,6 +101,7 @@ describe('Report Model', () => { expect(report.isPending()).toBe(false); }); + // Test resolved status check test('isResolved returns true for resolved report', async () => { const report = await Report.create({ reportId: 30, userId: 100, placeId: 200, description: 'Test', status: 'RESOLVED' @@ -102,6 +109,7 @@ describe('Report Model', () => { expect(report.isResolved()).toBe(true); }); + // Test resolved status for pending report test('isResolved returns false for pending report', async () => { const report = await Report.create({ reportId: 31, userId: 100, placeId: 200, description: 'Test' diff --git a/tests/unit/models.Review.test.js b/tests/unit/models.Review.test.js index 451db05..8ff6129 100644 --- a/tests/unit/models.Review.test.js +++ b/tests/unit/models.Review.test.js @@ -11,19 +11,23 @@ import { } from '../helpers/mongoDbSetup.js'; describe('Review Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); describe('Static Methods', () => { + // Create test reviews with various ratings beforeEach(async () => { await Review.create({ reviewId: 1, userId: 100, placeId: 200, rating: 5, comment: 'Great' }); await Review.create({ reviewId: 2, userId: 100, placeId: 201, rating: 4, comment: 'Good' }); @@ -79,6 +83,7 @@ describe('Review Model', () => { expect(review.isOwnedBy(999)).toBe(false); }); + // Test positive review classification (rating >= 4) test('isPositive returns true for rating >= 4', async () => { const review4 = await Review.create({ reviewId: 20, userId: 100, placeId: 200, rating: 4 }); const review5 = await Review.create({ reviewId: 21, userId: 100, placeId: 201, rating: 5 }); @@ -86,6 +91,7 @@ describe('Review Model', () => { expect(review5.isPositive()).toBe(true); }); + // Test non-positive review classification test('isPositive returns false for rating < 4', async () => { const review = await Review.create({ reviewId: 22, userId: 100, placeId: 200, rating: 3 @@ -93,6 +99,7 @@ describe('Review Model', () => { expect(review.isPositive()).toBe(false); }); + // Test negative review classification (rating <= 2) test('isNegative returns true for rating <= 2', async () => { const review1 = await Review.create({ reviewId: 30, userId: 100, placeId: 200, rating: 1 }); const review2 = await Review.create({ reviewId: 31, userId: 100, placeId: 201, rating: 2 }); @@ -100,6 +107,7 @@ describe('Review Model', () => { expect(review2.isNegative()).toBe(true); }); + // Test non-negative review classification test('isNegative returns false for rating > 2', async () => { const review = await Review.create({ reviewId: 32, userId: 100, placeId: 200, rating: 3 diff --git a/tests/unit/models.Settings.test.js b/tests/unit/models.Settings.test.js index 9203a65..86bd78f 100644 --- a/tests/unit/models.Settings.test.js +++ b/tests/unit/models.Settings.test.js @@ -11,14 +11,17 @@ import { } from '../helpers/mongoDbSetup.js'; describe('Settings Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); @@ -74,6 +77,7 @@ describe('Settings Model', () => { expect(updated.emailNotifications).toBe(false); }); + // Test language update with valid language code test('setLanguage updates to supported language', async () => { const settings = await Settings.create({ userId: 102, language: 'el' }); await settings.setLanguage('en'); @@ -82,11 +86,13 @@ describe('Settings Model', () => { expect(updated.language).toBe('en'); }); + // Test language validation for unsupported codes test('setLanguage throws for unsupported language', async () => { const settings = await Settings.create({ userId: 103, language: 'el' }); expect(() => settings.setLanguage('invalid')).toThrow('Unsupported language'); }); + // Test push notification toggle from disabled to enabled test('togglePushNotifications toggles from false to true', async () => { const settings = await Settings.create({ userId: 104, pushNotifications: false }); await settings.togglePushNotifications(); @@ -95,6 +101,7 @@ describe('Settings Model', () => { expect(updated.pushNotifications).toBe(true); }); + // Test push notification toggle from enabled to disabled test('togglePushNotifications toggles from true to false', async () => { const settings = await Settings.create({ userId: 105, pushNotifications: true }); await settings.togglePushNotifications(); diff --git a/tests/unit/models.User.test.js b/tests/unit/models.User.test.js index 098a246..21e32a5 100644 --- a/tests/unit/models.User.test.js +++ b/tests/unit/models.User.test.js @@ -11,19 +11,23 @@ import { } from '../helpers/mongoDbSetup.js'; describe('User Model', () => { + // Initialize test database connection beforeAll(async () => { await setupMongoDb(); }); + // Clean up database connection afterAll(async () => { await teardownMongoDb(); }); + // Clear data between tests for isolation beforeEach(async () => { await clearMongoDbData(); }); describe('Static Methods', () => { + // Create test users with different roles and attributes beforeEach(async () => { await User.create({ userId: 1, name: 'Admin User', email: 'admin@example.com', @@ -96,6 +100,7 @@ describe('User Model', () => { expect(user.hasLocation()).toBe(true); }); + // Test location validation with missing coordinates test('hasLocation returns false when location is missing', async () => { const user = await User.create({ userId: 21, name: 'No Location', email: 'noloc@test.com', @@ -104,6 +109,7 @@ describe('User Model', () => { expect(user.hasLocation()).toBe(false); }); + // Test location validation with incomplete data test('hasLocation returns false for partial location', async () => { const user = await User.create({ userId: 22, name: 'Partial', email: 'partial@test.com', @@ -112,6 +118,7 @@ describe('User Model', () => { expect(user.hasLocation()).toBe(false); }); + // Test preference checking for existing category test('hasPreference returns true for existing category', async () => { const user = await User.create({ userId: 30, name: 'Prefs', email: 'prefs@test.com', @@ -120,6 +127,7 @@ describe('User Model', () => { expect(user.hasPreference('Food')).toBe(true); }); + // Test preference checking for non-existent category test('hasPreference returns false for missing category', async () => { const user = await User.create({ userId: 31, name: 'Prefs2', email: 'prefs2@test.com', @@ -128,6 +136,7 @@ describe('User Model', () => { expect(user.hasPreference('Entertainment')).toBe(false); }); + // Test preference checking when no preferences defined test('hasPreference returns false when no preferences', async () => { const user = await User.create({ userId: 32, name: 'NoPrefs', email: 'noprefs@test.com', @@ -136,6 +145,7 @@ describe('User Model', () => { expect(user.hasPreference('Food')).toBe(false); }); + // Test active profile assignment test('setActiveProfile updates activeProfile and saves', async () => { const user = await User.create({ userId: 40, name: 'Profile', email: 'profile@test.com', diff --git a/tests/unit/utils.controllerValidators.test.js b/tests/unit/utils.controllerValidators.test.js index 32e7b1e..bddda15 100644 --- a/tests/unit/utils.controllerValidators.test.js +++ b/tests/unit/utils.controllerValidators.test.js @@ -31,11 +31,13 @@ const { default: R } = await import('../../utils/responseBuilder.js'); describe('Controller Validators', () => { let res; + // Reset mocks before each test beforeEach(() => { res = {}; jest.clearAllMocks(); }); + // Test requireUser validator function describe('requireUser', () => { it('should return user if found', async () => { const user = { id: 1, name: 'Test User' }; diff --git a/tests/unit/utils.geoUtils.test.js b/tests/unit/utils.geoUtils.test.js index f48db33..60692db 100644 --- a/tests/unit/utils.geoUtils.test.js +++ b/tests/unit/utils.geoUtils.test.js @@ -25,12 +25,18 @@ describe('Geo Utilities', () => { const londonLon = -0.1278; // Approximate distance is 5570 km - const distance = calculateDistance(nyLat, nyLon, londonLat, londonLon); + const distance = calculateDistance( + { latitude: nyLat, longitude: nyLon }, + { latitude: londonLat, longitude: londonLon } + ); expect(distance).toBeCloseTo(5570.2, 0); }); it('should return 0 for same location', () => { - expect(calculateDistance(10, 10, 10, 10)).toBe(0); + expect(calculateDistance( + { latitude: 10, longitude: 10 }, + { latitude: 10, longitude: 10 } + )).toBe(0); }); }); }); diff --git a/tests/unit/utils.responseBuilder.test.js b/tests/unit/utils.responseBuilder.test.js index 8029e95..dbcec1d 100644 --- a/tests/unit/utils.responseBuilder.test.js +++ b/tests/unit/utils.responseBuilder.test.js @@ -1,5 +1,7 @@ /** * @fileoverview Response Builder Utility Tests + * Comprehensive test suite for validating standardized API response formatting + * Tests both success and error response builders with various status codes * @module tests/unit/utils.responseBuilder.test * @requires ../../utils/responseBuilder */ @@ -10,21 +12,41 @@ import { jest } from '@jest/globals'; describe('Response Builder', () => { let res; + /** + * Setup mock response object before each test + * Provides chainable mock methods for status, json, and send + */ beforeEach(() => { res = { + // Mock status setter that returns itself for chaining status: jest.fn().mockReturnThis(), + // Mock JSON response method json: jest.fn().mockReturnThis(), + // Mock send method for empty responses send: jest.fn().mockReturnThis() }; }); + /** + * Test suite for successful response formatting + * Validates default and custom status codes with data payloads + */ describe('success', () => { + /** + * Verify success response with default 200 OK status + * Tests standard success response structure + */ it('should send success response with default status 200', () => { + // Prepare test data and message const data = { id: 1 }; const message = 'Success'; + + // Execute the success response builder R.success(res, data, message); + // Verify correct status code is set expect(res.status).toHaveBeenCalledWith(200); + // Verify response body structure matches API contract expect(res.json).toHaveBeenCalledWith({ success: true, data, @@ -33,22 +55,41 @@ describe('Response Builder', () => { }); }); + /** + * Verify success response with custom status code + * Tests flexibility of status code parameter + */ it('should send success response with custom status', () => { + // Execute with 201 Created status R.success(res, null, 'Created', 201); + + // Verify custom status is applied expect(res.status).toHaveBeenCalledWith(201); }); }); + /** + * Test suite for error response formatting + * Validates error responses with and without additional details + */ describe('error', () => { + /** + * Test error response with full details object + * Verifies comprehensive error information is included + */ it('should send error response with details', () => { + // Define all error properties const code = 'ERROR_CODE'; const message = 'Error message'; const status = 400; const details = { field: 'test' }; + // Execute the error response builder R.error(res, { code, message, status, details }); + // Verify correct error status code expect(res.status).toHaveBeenCalledWith(status); + // Verify error response structure includes all fields expect(res.json).toHaveBeenCalledWith({ success: false, data: null, @@ -58,8 +99,15 @@ describe('Response Builder', () => { }); }); + /** + * Test error response without optional details + * Ensures details field is omitted when not provided + */ it('should send error response without details', () => { + // Execute with only required error fields R.error(res, { code: 'ERROR', message: 'Message', status: 500 }); + + // Verify response excludes details field expect(res.json).toHaveBeenCalledWith({ success: false, data: null, @@ -69,34 +117,58 @@ describe('Response Builder', () => { }); }); + /** + * Test suite for HTTP status convenience methods + * Validates shorthand methods for common HTTP responses + */ describe('convenience methods', () => { + /** + * Test 404 Not Found convenience method + */ it('notFound should send 404', () => { R.notFound(res, 'NOT_FOUND', 'Not found'); expect(res.status).toHaveBeenCalledWith(404); }); + /** + * Test 400 Bad Request convenience method + */ it('badRequest should send 400', () => { R.badRequest(res, 'BAD_REQUEST', 'Bad request'); expect(res.status).toHaveBeenCalledWith(400); }); + /** + * Test 409 Conflict convenience method + */ it('conflict should send 409', () => { R.conflict(res, 'CONFLICT', 'Conflict'); expect(res.status).toHaveBeenCalledWith(409); }); + /** + * Test 403 Forbidden convenience method + */ it('forbidden should send 403', () => { R.forbidden(res, 'FORBIDDEN', 'Forbidden'); expect(res.status).toHaveBeenCalledWith(403); }); + /** + * Test 401 Unauthorized convenience method + */ it('unauthorized should send 401', () => { R.unauthorized(res, 'UNAUTHORIZED', 'Unauthorized'); expect(res.status).toHaveBeenCalledWith(401); }); + /** + * Test 204 No Content convenience method + * Verifies empty response with appropriate status + */ it('noContent should send 204', () => { R.noContent(res); + // Verify status and that send (not json) is called expect(res.status).toHaveBeenCalledWith(204); expect(res.send).toHaveBeenCalled(); }); diff --git a/utils/controllerValidators.js b/utils/controllerValidators.js index 7bbea79..354d006 100644 --- a/utils/controllerValidators.js +++ b/utils/controllerValidators.js @@ -1,6 +1,8 @@ +// Controller validators for common resource validation patterns import db from '../config/db.js'; import R from './responseBuilder.js'; +// Validate user exists and return user or send 404 response export const requireUser = async (res, userId) => { const user = await db.findUserById(userId); if (!user) { @@ -10,6 +12,7 @@ export const requireUser = async (res, userId) => { return user; }; +// Validate place exists and return place or send 404 response export const requirePlace = async (res, placeId) => { const place = await db.findPlaceById(placeId); if (!place) { @@ -19,11 +22,14 @@ export const requirePlace = async (res, placeId) => { return place; }; +// Validate both user and place exist, return both or send error response export const requireUserAndPlace = async (res, userId, placeId) => { + // Check placeId is provided if (!placeId) { R.badRequest(res, 'INVALID_INPUT', 'Place ID is required', { field: 'placeId' }); return null; } + // Fetch both resources concurrently const user = await db.findUserById(userId); const place = await db.findPlaceById(placeId); if (!user || !place) { diff --git a/utils/geoUtils.js b/utils/geoUtils.js index 78d62ec..2e6f6e2 100644 --- a/utils/geoUtils.js +++ b/utils/geoUtils.js @@ -14,19 +14,21 @@ export const toRad = (degrees) => { /** * Calculate distance between two coordinates using Haversine formula - * @param {number} lat1 - Latitude of first point - * @param {number} lon1 - Longitude of first point - * @param {number} lat2 - Latitude of second point - * @param {number} lon2 - Longitude of second point + * @param {Object} from - Starting point coordinates + * @param {number} from.latitude - Latitude of first point + * @param {number} from.longitude - Longitude of first point + * @param {Object} to - Ending point coordinates + * @param {number} to.latitude - Latitude of second point + * @param {number} to.longitude - Longitude of second point * @returns {number} Distance in kilometers */ -export const calculateDistance = (lat1, lon1, lat2, lon2) => { +export const calculateDistance = (from, to) => { const R = 6371; // Earth's radius in kilometers - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); + const dLat = toRad(to.latitude - from.latitude); + const dLon = toRad(to.longitude - from.longitude); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.cos(toRad(from.latitude)) * Math.cos(toRad(to.latitude)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); diff --git a/utils/hateoas/placeLinks.js b/utils/hateoas/placeLinks.js index 21acbd5..590d1d7 100644 --- a/utils/hateoas/placeLinks.js +++ b/utils/hateoas/placeLinks.js @@ -2,10 +2,13 @@ * HATEOAS Place Links - Link generators for place resources */ +// Helper to create link object with method and optional type const link = (href, method = 'GET', type = null) => type ? { href, method, type } : { href, method }; +// Helper to conditionally add user profile link const userProfile = (userId) => userId ? { 'user-profile': link(`/users/${userId}/profile`) } : {}; const placeLinks = { + // Generate standard place links with optional user context place: (placeId, userId = null) => { const links = { self: link(`/places/${placeId}`), @@ -22,12 +25,14 @@ const placeLinks = { return links; }, + // Place links with optional website link appended placeWithWebsite: (placeId, websiteUrl, userId = null) => { const links = placeLinks.place(placeId, userId); if (websiteUrl) links.website = link(websiteUrl, 'GET', 'text/html'); return links; }, + // Links for reviews collection of a place reviews: (placeId, userId = null) => ({ self: link(`/places/${placeId}/reviews`), 'add-review': link(`/places/${placeId}/reviews`, 'POST'), @@ -35,28 +40,33 @@ const placeLinks = { ...userProfile(userId) }), + // Links for a single review review: (placeId, userId = null) => ({ self: link(`/places/${placeId}/reviews`), place: link(`/places/${placeId}`), ...userProfile(userId) }), + // Links for report submission report: (placeId, userId = null) => ({ place: link(`/places/${placeId}`), ...userProfile(userId) }), + // Links for place search results search: (userId = null) => ({ 'refine-search': link(`/places/search{?keywords}`), ...userProfile(userId) }), + // Links for navigation to a place navigation: (placeId = null, userId = null) => ({ ...(placeId ? { destination: link(`/places/${placeId}`) } : {}), 'alternative-routes': link(`/navigation{?userLocation,placeLocation,transportationMode}`), ...userProfile(userId) }), + // Simple select link for a place selectLink: (placeId) => ({ select: link(`/places/${placeId}`) }) }; diff --git a/utils/hateoas/userLinks.js b/utils/hateoas/userLinks.js index 0e51a31..d1ed98c 100644 --- a/utils/hateoas/userLinks.js +++ b/utils/hateoas/userLinks.js @@ -2,9 +2,10 @@ * HATEOAS User Links - Link generators for user resources */ -// Helper: Create a link object +// Helper: Create a link object with href and method const link = (href, method = 'GET') => ({ href, method }); +// Generate all path templates for a user ID const getPaths = (u) => ({ self: `/users/${u}`, profile: `/users/${u}/profile`, @@ -16,9 +17,11 @@ const getPaths = (u) => ({ favourite: (f) => `/users/${u}/favourite-places/${f}` }); +// Generate path to a place resource const placePath = (p) => `/places/${p}`; const userLinks = { + // Links for user profile page userProfile: (userId) => { const p = getPaths(userId); return { @@ -32,6 +35,7 @@ const userLinks = { }; }, + // Links for user settings page settings: (userId) => { const p = getPaths(userId); return { @@ -41,6 +45,7 @@ const userLinks = { }; }, + // Links for preference profiles collection preferenceProfilesCollection: (userId) => { const p = getPaths(userId); return { @@ -50,6 +55,7 @@ const userLinks = { }; }, + // Links for individual preference profile preferenceProfile: (userId, profileId) => { const p = getPaths(userId); const prefPath = p.preference(profileId); @@ -63,6 +69,7 @@ const userLinks = { }; }, + // Links for recommendations page recommendations: (userId) => { const p = getPaths(userId); return { @@ -71,6 +78,7 @@ const userLinks = { }; }, + // Links for favourites collection page favouritesCollection: (userId) => { const p = getPaths(userId); return { @@ -80,6 +88,7 @@ const userLinks = { }; }, + // Links for individual favourite with place details favourite: (userId, favouriteId, placeId) => { const p = getPaths(userId); return { @@ -90,6 +99,7 @@ const userLinks = { }; }, + // Simplified links for favourite item in list view favouriteItem: (userId, favouriteId, placeId) => { const p = getPaths(userId); return { @@ -98,6 +108,7 @@ const userLinks = { }; }, + // Links for disliked place action result dislikedPlace: (userId) => { const p = getPaths(userId); return { diff --git a/utils/index.js b/utils/index.js new file mode 100644 index 0000000..bffe417 --- /dev/null +++ b/utils/index.js @@ -0,0 +1,12 @@ +/** + * Utilities Barrier File + * Centralizes utility exports + */ + +export * from './errors.js'; +export * from './validators.js'; +export * from './helpers.js'; +export * from './controllerValidators.js'; +export * from './geoUtils.js'; +export * from './responseBuilder.js'; +export * from './responses.js'; diff --git a/utils/responseBuilder.js b/utils/responseBuilder.js index a41aa2d..d66805f 100644 --- a/utils/responseBuilder.js +++ b/utils/responseBuilder.js @@ -1,3 +1,6 @@ +// Standardized response builder utilities for consistent API responses + +// Send successful response with data and message const success = (res, data, message, status = 200) => { return res.status(status).json({ success: true, @@ -7,12 +10,14 @@ const success = (res, data, message, status = 200) => { }); }; +// Send error response with code, message, and optional details const error = (res, { code, message, status, details = null }) => { const body = { success: false, data: null, error: code, message }; if (details) body.details = details; return res.status(status).json(body); }; +// Convenience methods for common HTTP status codes const notFound = (res, code, message) => error(res, { code, message, status: 404 }); const badRequest = (res, code, message, details) => error(res, { code, message, status: 400, details }); const conflict = (res, code, message, details) => error(res, { code, message, status: 409, details });