diff --git a/Client/package-lock.json b/Client/package-lock.json index 145d2783b..83ffa6cfa 100644 --- a/Client/package-lock.json +++ b/Client/package-lock.json @@ -15,8 +15,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.16", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.22.1", - "@mui/x-date-pickers": "7.22.1", + "@mui/x-data-grid": "7.22.2", + "@mui/x-date-pickers": "7.22.2", "@reduxjs/toolkit": "2.3.0", "axios": "^1.7.4", "chart.js": "^4.4.3", @@ -1400,9 +1400,9 @@ } }, "node_modules/@mui/x-charts": { - "version": "7.22.1", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.1.tgz", - "integrity": "sha512-zgr8CN4yLen5puqaX7Haj5+AoVG7E13HHsIiDoEAuQvuFDF0gKTxTTdLSKXqhd1qJUIIzJaztZtrr3YCVrENqw==", + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.2.tgz", + "integrity": "sha512-0Y2du4Ed7gOT53l8vVJ4vKT+Jz4Dh/iHnLy8TtL3+XhbPH9Ndu9Q30WwyyzOn84yt37hSUru/njQ1BWaSvVPHw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1458,9 +1458,9 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "7.22.1", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.1.tgz", - "integrity": "sha512-YHF96MEv7ACG/VuiycZjEAPH7cZLNuV2+bi/MyR1t/e6E6LTolYFykvjSFq+Imz1mYbW4+9mEvrHZsIKL5KKIQ==", + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.2.tgz", + "integrity": "sha512-yfy2s5A6tbajQZiEdsba49T4FYb9F0WPrzbbG30dl1+sIiX4ZRX7ma44UIDGPZrsZv8xkkE+p8qeJxZ7OaMteA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1495,9 +1495,9 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.22.1", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.1.tgz", - "integrity": "sha512-VBgicE+7PvJrdHSL6HyieHT6a/0dENH8RaMIM2VwUFrGoZzvik50WNwY5U+Hip1BwZLIEvlqtNRQIIj6kgBR6Q==", + "version": "7.22.2", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.2.tgz", + "integrity": "sha512-1KHSlIlnSoY3oHm820By8X344pIdGYqPvCCvfVHrEeeIQ/pHdxDD8tjZFWkFl4Jgm9oVFK90fMcqNZAzc+WaCw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", @@ -1525,7 +1525,7 @@ "dayjs": "^1.10.7", "luxon": "^3.0.2", "moment": "^2.29.4", - "moment-hijri": "^2.1.2", + "moment-hijri": "^2.1.2 || ^3.0.0", "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" diff --git a/Client/package.json b/Client/package.json index 1a7213c20..eec7eea4b 100644 --- a/Client/package.json +++ b/Client/package.json @@ -18,8 +18,8 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.16", "@mui/x-charts": "^7.5.1", - "@mui/x-data-grid": "7.22.1", - "@mui/x-date-pickers": "7.22.1", + "@mui/x-data-grid": "7.22.2", + "@mui/x-date-pickers": "7.22.2", "@reduxjs/toolkit": "2.3.0", "axios": "^1.7.4", "chart.js": "^4.4.3", diff --git a/Server/.nycrc b/Server/.nycrc index 83b717bb5..159e02a0d 100644 --- a/Server/.nycrc +++ b/Server/.nycrc @@ -1,6 +1,11 @@ { "all": true, - "include": ["controllers/*.js", "utils/*.js", "service/*.js"], + "include": [ + "controllers/*.js", + "utils/*.js", + "service/*.js", + "db/mongo/modules/monitorModule.js" + ], "exclude": ["**/*.test.js"], "reporter": ["html", "text", "lcov"], "sourceMap": false, diff --git a/Server/controllers/monitorController.js b/Server/controllers/monitorController.js index 06dcb9998..0615578c4 100644 --- a/Server/controllers/monitorController.js +++ b/Server/controllers/monitorController.js @@ -45,6 +45,28 @@ const getAllMonitors = async (req, res, next) => { } }; +/** + * Returns all monitors with uptime stats for 1,7,30, and 90 days + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @param {function} next + * @returns {Promise} + * @throws {Error} + */ +const getAllMonitorsWithUptimeStats = async (req, res, next) => { + try { + const monitors = await req.db.getAllMonitorsWithUptimeStats(); + return res.status(200).json({ + success: true, + msg: successMessages.MONITOR_GET_ALL, + data: monitors, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getAllMonitorsWithUptimeStats")); + } +}; + /** * Returns monitor stats for monitor with matching ID * @async @@ -495,6 +517,7 @@ const addDemoMonitors = async (req, res, next) => { export { getAllMonitors, + getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorCertificate, getMonitorById, diff --git a/Server/db/mongo/MongoDB.js b/Server/db/mongo/MongoDB.js index 22460eff4..581a9e588 100644 --- a/Server/db/mongo/MongoDB.js +++ b/Server/db/mongo/MongoDB.js @@ -99,6 +99,7 @@ import { import { getAllMonitors, + getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorById, getMonitorsAndSummaryByTeamId, @@ -187,6 +188,7 @@ export default { resetPassword, checkSuperadmin, getAllMonitors, + getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorById, getMonitorsAndSummaryByTeamId, diff --git a/Server/db/mongo/modules/checkModule.js b/Server/db/mongo/modules/checkModule.js index 2457d7131..773d6ef1e 100644 --- a/Server/db/mongo/modules/checkModule.js +++ b/Server/db/mongo/modules/checkModule.js @@ -48,7 +48,6 @@ const createCheck = async (checkData) => { } await monitor.save(); - return check; } catch (error) { error.service = SERVICE_NAME; @@ -105,7 +104,7 @@ const getChecks = async (req) => { const { monitorId } = req.params; let { sortOrder, limit, dateRange, filter, page, rowsPerPage } = req.query; // Default limit to 0 if not provided - limit = limit === "undefined" ? 0 : limit; + limit = limit === undefined ? 0 : limit; // Default sort order is newest -> oldest sortOrder = sortOrder === "asc" ? 1 : -1; @@ -155,58 +154,64 @@ const getChecks = async (req) => { }; const getTeamChecks = async (req) => { - const { teamId } = req.params; - let { sortOrder, limit, dateRange, filter, page, rowsPerPage } = req.query; + try { + const { teamId } = req.params; + let { sortOrder, limit, dateRange, filter, page, rowsPerPage } = req.query; - // Get monitorIDs - const userMonitors = await Monitor.find({ teamId: teamId }).select("_id"); + // Get monitorIDs + const userMonitors = await Monitor.find({ teamId: teamId }).select("_id"); - //Build check query - // Default limit to 0 if not provided - limit = limit === undefined ? 0 : limit; - // Default sort order is newest -> oldest - sortOrder = sortOrder === "asc" ? 1 : -1; + //Build check query + // Default limit to 0 if not provided + limit = limit === undefined ? 0 : limit; + // Default sort order is newest -> oldest + sortOrder = sortOrder === "asc" ? 1 : -1; - const checksQuery = { monitorId: { $in: userMonitors } }; + const checksQuery = { monitorId: { $in: userMonitors } }; - if (filter !== undefined) { - checksQuery.status = false; - switch (filter) { - case "all": - break; - case "down": - break; - case "resolve": - checksQuery.statusCode = 5000; - break; - default: - logger.warn({ - message: "invalid filter", - service: SERVICE_NAME, - method: "getTeamChecks", - }); - break; + if (filter !== undefined) { + checksQuery.status = false; + switch (filter) { + case "all": + break; + case "down": + break; + case "resolve": + checksQuery.statusCode = 5000; + break; + default: + logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getTeamChecks", + }); + break; + } } + + if (dateRange !== undefined) { + checksQuery.createdAt = { $gte: dateRangeLookup[dateRange] }; + } + + // Skip and limit for pagination + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + const checksCount = await Check.countDocuments(checksQuery); + + const checks = await Check.find(checksQuery) + .skip(skip) + .limit(rowsPerPage) + .sort({ createdAt: sortOrder }) + .select(["monitorId", "status", "responseTime", "statusCode", "message"]); + return { checksCount, checks }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getTeamChecks"; + throw error; } - - if (dateRange !== undefined) { - checksQuery.createdAt = { $gte: dateRangeLookup[dateRange] }; - } - - // Skip and limit for pagination - let skip = 0; - if (page && rowsPerPage) { - skip = page * rowsPerPage; - } - - const checksCount = await Check.countDocuments(checksQuery); - - const checks = await Check.find(checksQuery) - .skip(skip) - .limit(rowsPerPage) - .sort({ createdAt: sortOrder }) - .select(["monitorId", "status", "responseTime", "statusCode", "message"]); - return { checksCount, checks }; }; /** diff --git a/Server/db/mongo/modules/monitorModule.js b/Server/db/mongo/modules/monitorModule.js index 391e02146..28bf89d82 100644 --- a/Server/db/mongo/modules/monitorModule.js +++ b/Server/db/mongo/modules/monitorModule.js @@ -20,7 +20,7 @@ const SERVICE_NAME = "monitorModule"; const CHECK_MODEL_LOOKUP = { http: Check, ping: Check, - pageSpeed: PageSpeedCheck, + pagespeed: PageSpeedCheck, hardware: HardwareCheck, }; @@ -43,6 +43,53 @@ const getAllMonitors = async (req, res) => { } }; +/** + * Get all monitors with uptime stats for 1,7,30, and 90 days + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise>} + * @throws {Error} + */ +const getAllMonitorsWithUptimeStats = async () => { + const timeRanges = { + 1: new Date(Date.now() - 24 * 60 * 60 * 1000), + 7: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + 30: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + 90: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), + }; + + try { + const monitors = await Monitor.find(); + const monitorsWithStats = await Promise.all( + monitors.map(async (monitor) => { + const model = CHECK_MODEL_LOOKUP[monitor.type]; + + const uptimeStats = await Promise.all( + Object.entries(timeRanges).map(async ([days, startDate]) => { + const checks = await model.find({ + monitorId: monitor._id, + createdAt: { $gte: startDate }, + }); + return [days, getUptimePercentage(checks)]; + }) + ); + + return { + ...monitor.toObject(), + ...Object.fromEntries(uptimeStats), + }; + }) + ); + + return monitorsWithStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getAllMonitorsWithUptimeStats"; + throw error; + } +}; + /** * Function to calculate uptime duration based on the most recent check. * @param {Array} checks Array of check objects. @@ -52,11 +99,10 @@ const calculateUptimeDuration = (checks) => { if (!checks || checks.length === 0) { return 0; } - const latestCheck = new Date(checks[0].createdAt); let latestDownCheck = 0; - for (let i = checks.length; i <= 0; i--) { + for (let i = checks.length - 1; i >= 0; i--) { if (checks[i].status === false) { latestDownCheck = new Date(checks[i].createdAt); break; @@ -94,7 +140,8 @@ const getLatestResponseTime = (checks) => { if (!checks || checks.length === 0) { return 0; } - return checks[0].responseTime; + + return checks[0]?.responseTime ?? 0; }; /** @@ -106,14 +153,19 @@ const getAverageResponseTime = (checks) => { if (!checks || checks.length === 0) { return 0; } - const aggResponseTime = checks.reduce((sum, check) => { + + const validChecks = checks.filter((check) => typeof check.responseTime === "number"); + if (validChecks.length === 0) { + return 0; + } + const aggResponseTime = validChecks.reduce((sum, check) => { return sum + check.responseTime; }, 0); - return aggResponseTime / checks.length; + return aggResponseTime / validChecks.length; }; /** - * Helper function to get precentage 24h uptime + * Helper function to get percentage 24h uptime * @param {Array} checks Array of check objects. * @returns {number} Timestamp of the most recent check. */ @@ -143,6 +195,112 @@ const getIncidents = (checks) => { }, 0); }; +/** + * Get date range parameters + * @param {string} dateRange - 'day' | 'week' | 'month' + * @returns {Object} Start and end dates + */ +const getDateRange = (dateRange) => { + const startDates = { + day: new Date(new Date().setDate(new Date().getDate() - 1)), + week: new Date(new Date().setDate(new Date().getDate() - 7)), + month: new Date(new Date().setMonth(new Date().getMonth() - 1)), + }; + return { + start: startDates[dateRange], + end: new Date(), + }; +}; + +/** + * Get checks for a monitor + * @param {string} monitorId - Monitor ID + * @param {Object} model - Check model to use + * @param {Object} dateRange - Date range parameters + * @param {number} sortOrder - Sort order (1 for ascending, -1 for descending) + * @returns {Promise} All checks and date-ranged checks + */ +const getMonitorChecks = async (monitorId, model, dateRange, sortOrder) => { + const [checksAll, checksForDateRange] = await Promise.all([ + model.find({ monitorId }).sort({ createdAt: sortOrder }), + model + .find({ + monitorId, + createdAt: { $gte: dateRange.start, $lte: dateRange.end }, + }) + .sort({ createdAt: sortOrder }), + ]); + return { checksAll, checksForDateRange }; +}; + +/** + * Process checks for display + * @param {Array} checks - Checks to process + * @param {number} numToDisplay - Number of checks to display + * @param {boolean} normalize - Whether to normalize the data + * @returns {Array} Processed checks + */ +const processChecksForDisplay = (normalizeData, checks, numToDisplay, normalize) => { + let processedChecks = checks; + if (numToDisplay && checks.length > numToDisplay) { + const n = Math.ceil(checks.length / numToDisplay); + processedChecks = checks.filter((_, index) => index % n === 0); + } + return normalize ? normalizeData(processedChecks, 1, 100) : processedChecks; +}; + +/** + * Get time-grouped checks based on date range + * @param {Array} checks Array of check objects + * @param {string} dateRange 'day' | 'week' | 'month' + * @returns {Object} Grouped checks by time period + */ +const groupChecksByTime = (checks, dateRange) => { + return checks.reduce((acc, check) => { + // Validate the date + const checkDate = new Date(check.createdAt); + if (Number.isNaN(checkDate.getTime()) || checkDate.getTime() === 0) { + return acc; + } + + const time = + dateRange === "day" + ? checkDate.setMinutes(0, 0, 0) + : checkDate.toISOString().split("T")[0]; + + if (!acc[time]) { + acc[time] = { time, checks: [] }; + } + acc[time].checks.push(check); + return acc; + }, {}); +}; + +/** + * Calculate aggregate stats for a group of checks + * @param {Object} group Group of checks + * @returns {Object} Stats for the group + */ +const calculateGroupStats = (group) => { + const totalChecks = group.checks.length; + + const checksWithResponseTime = group.checks.filter( + (check) => typeof check.responseTime === "number" && !Number.isNaN(check.responseTime) + ); + + return { + time: group.time, + uptimePercentage: getUptimePercentage(group.checks), + totalChecks, + totalIncidents: group.checks.filter((check) => !check.status).length, + avgResponseTime: + checksWithResponseTime.length > 0 + ? checksWithResponseTime.reduce((sum, check) => sum + check.responseTime, 0) / + checksWithResponseTime.length + : 0, + }; +}; + /** * Get stats by monitor ID * @async @@ -152,129 +310,53 @@ const getIncidents = (checks) => { * @throws {Error} */ const getMonitorStatsById = async (req) => { - const startDates = { - day: new Date(new Date().setDate(new Date().getDate() - 1)), - week: new Date(new Date().setDate(new Date().getDate() - 7)), - month: new Date(new Date().setMonth(new Date().getMonth() - 1)), - }; - const endDate = new Date(); try { - // Get monitor const { monitorId } = req.params; - let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query; + + // Get monitor, if we can't find it, abort with error const monitor = await Monitor.findById(monitorId); if (monitor === null || monitor === undefined) { throw new Error(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)); } - // This effectively removes limit, returning all checks - if (limit === undefined) limit = 0; - // Default sort order is newest -> oldest - sortOrder = sortOrder === "asc" ? 1 : -1; - let model = CHECK_MODEL_LOOKUP[monitor.type]; + // Get query params + let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query; + const sort = sortOrder === "asc" ? 1 : -1; + // Get Checks for monitor in date range requested + const model = CHECK_MODEL_LOOKUP[monitor.type]; + const dates = getDateRange(dateRange); + const { checksAll, checksForDateRange } = await getMonitorChecks( + monitorId, + model, + dates, + sort + ); + + // Build monitor stats const monitorStats = { ...monitor.toObject(), + uptimeDuration: calculateUptimeDuration(checksAll), + lastChecked: getLastChecked(checksAll), + latestResponseTime: getLatestResponseTime(checksAll), + periodIncidents: getIncidents(checksForDateRange), + periodTotalChecks: checksForDateRange.length, + checks: processChecksForDisplay( + NormalizeData, + checksForDateRange, + numToDisplay, + normalize + ), }; - // Build checks query - const checksQuery = { monitorId: monitor._id }; - - // Get all checks - const checksAll = await model.find(checksQuery).sort({ - createdAt: sortOrder, - }); - - const checksQueryForDateRange = { - ...checksQuery, - createdAt: { - $gte: startDates[dateRange], - $lte: endDate, - }, - }; - - const checksForDateRange = await model - .find(checksQueryForDateRange) - .sort({ createdAt: sortOrder }); - if (monitor.type === "http" || monitor.type === "ping") { // HTTP/PING Specific stats monitorStats.periodAvgResponseTime = getAverageResponseTime(checksForDateRange); monitorStats.periodUptime = getUptimePercentage(checksForDateRange); - - // Aggregate data - let groupedChecks; - // Group checks by hour if range is day - if (dateRange === "day") { - groupedChecks = checksForDateRange.reduce((acc, check) => { - const time = new Date(check.createdAt); - time.setMinutes(0, 0, 0); - if (!acc[time]) { - acc[time] = { time, checks: [] }; - } - acc[time].checks.push(check); - return acc; - }, {}); - } else { - groupedChecks = checksForDateRange.reduce((acc, check) => { - const time = new Date(check.createdAt).toISOString().split("T")[0]; // Extract the date part - if (!acc[time]) { - acc[time] = { time, checks: [] }; - } - acc[time].checks.push(check); - return acc; - }, {}); - } - - // Map grouped checks to stats - const aggregateData = Object.values(groupedChecks).map((group) => { - const totalChecks = group.checks.length; - const uptimePercentage = getUptimePercentage(group.checks); - const totalIncidents = group.checks.filter( - (check) => check.status === false - ).length; - const avgResponseTime = - group.checks.reduce((sum, check) => sum + check.responseTime, 0) / totalChecks; - - return { - time: group.time, - uptimePercentage, - totalChecks, - totalIncidents, - avgResponseTime, - }; - }); - monitorStats.aggregateData = aggregateData; + const groupedChecks = groupChecksByTime(checksForDateRange, dateRange); + monitorStats.aggregateData = Object.values(groupedChecks).map(calculateGroupStats); } - monitorStats.periodIncidents = getIncidents(checksForDateRange); - monitorStats.periodTotalChecks = checksForDateRange.length; - - // If more than numToDisplay checks, pick every nth check - - let nthChecks = checksForDateRange; - - if ( - numToDisplay !== undefined && - checksForDateRange && - checksForDateRange.length > numToDisplay - ) { - const n = Math.ceil(checksForDateRange.length / numToDisplay); - nthChecks = checksForDateRange.filter((_, index) => index % n === 0); - } - - // Normalize checks if requested - if (normalize !== undefined) { - const normailzedChecks = NormalizeData(nthChecks, 1, 100); - monitorStats.checks = normailzedChecks; - } else { - monitorStats.checks = nthChecks; - } - - monitorStats.uptimeDuration = calculateUptimeDuration(checksAll); - monitorStats.lastChecked = getLastChecked(checksAll); - monitorStats.latestResponseTime = getLatestResponseTime(checksAll); - return monitorStats; } catch (error) { error.service = SERVICE_NAME; @@ -303,12 +385,12 @@ const getMonitorById = async (monitorId) => { const notifications = await Notification.find({ monitorId: monitorId, }); - const updatedMonitor = await Monitor.findByIdAndUpdate( - monitorId, - { notifications }, - { new: true } - ).populate("notifications"); - return updatedMonitor; + + // Update monitor with notifications and save + monitor.notifications = notifications; + await monitor.save(); + + return monitor; } catch (error) { error.service = SERVICE_NAME; error.method = "getMonitorById"; @@ -371,10 +453,12 @@ const getMonitorsByTeamId = async (req, res) => { filter, field, order, - } = req.query || {}; + } = req.query; + const monitorQuery = { teamId: req.params.teamId }; + if (type !== undefined) { - monitorQuery.type = type; + monitorQuery.type = Array.isArray(type) ? { $in: type } : type; } // Add filter if provided // $options: "i" makes the search case-insensitive @@ -384,29 +468,13 @@ const getMonitorsByTeamId = async (req, res) => { { url: { $regex: filter, $options: "i" } }, ]; } - const monitorsCount = await Monitor.countDocuments(monitorQuery); + const monitorCount = await Monitor.countDocuments(monitorQuery); // Pagination - let skip = 0; - if (page && rowsPerPage) { - skip = page * rowsPerPage; - } + const skip = page && rowsPerPage ? page * rowsPerPage : 0; - if (type !== undefined) { - const types = Array.isArray(type) ? type : [type]; - monitorQuery.type = { $in: types }; - } - - // Default sort order is newest -> oldest - if (checkOrder === "asc") { - checkOrder = 1; - } else checkOrder = -1; - - // Sort order for monitors - let sort = {}; - if (field !== undefined && order !== undefined) { - sort[field] = order === "asc" ? 1 : -1; - } + // Build Sort option + const sort = field ? { [field]: order === "asc" ? 1 : -1 } : {}; const monitors = await Monitor.find(monitorQuery) .skip(skip) @@ -415,29 +483,22 @@ const getMonitorsByTeamId = async (req, res) => { // Early return if limit is set to -1, indicating we don't want any checks if (limit === "-1") { - return { monitors, monitorCount: monitorsCount }; + return { monitors, monitorCount }; } - - // This effectively removes limit, returning all checks - if (limit === undefined) limit = 0; - // Map each monitor to include its associated checks const monitorsWithChecks = await Promise.all( monitors.map(async (monitor) => { - const checksQuery = { monitorId: monitor._id }; - if (status !== undefined) { - checksQuery.status = status; - } - let model = CHECK_MODEL_LOOKUP[monitor.type]; // Checks are order newest -> oldest let checks = await model - .find(checksQuery) - .sort({ - createdAt: checkOrder, + .find({ + monitorId: monitor._id, + ...(status && { status }), }) - .limit(limit); + .sort({ createdAt: checkOrder === "asc" ? 1 : -1 }) + + .limit(limit || 0); //Normalize checks if requested if (normalize !== undefined) { @@ -446,7 +507,7 @@ const getMonitorsByTeamId = async (req, res) => { return { ...monitor.toObject(), checks }; }) ); - return { monitors: monitorsWithChecks, monitorCount: monitorsCount }; + return { monitors: monitorsWithChecks, monitorCount }; } catch (error) { error.service = SERVICE_NAME; error.method = "getMonitorsByTeamId"; @@ -580,6 +641,7 @@ const addDemoMonitors = async (userId, teamId) => { export { getAllMonitors, + getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorById, getMonitorsAndSummaryByTeamId, @@ -591,3 +653,18 @@ export { editMonitor, addDemoMonitors, }; + +// Helper functions +export { + calculateUptimeDuration, + getLastChecked, + getLatestResponseTime, + getAverageResponseTime, + getUptimePercentage, + getIncidents, + getDateRange, + getMonitorChecks, + processChecksForDisplay, + groupChecksByTime, + calculateGroupStats, +}; diff --git a/Server/index.js b/Server/index.js index b83a3d5ce..e12d83a18 100644 --- a/Server/index.js +++ b/Server/index.js @@ -8,7 +8,6 @@ import cors from "cors"; import logger from "./utils/logger.js"; import { verifyJWT } from "./middleware/verifyJWT.js"; import { handleErrors } from "./middleware/handleErrors.js"; -import { errorMessages } from "./utils/messages.js"; import authRouter from "./routes/authRoute.js"; import inviteRouter from "./routes/inviteRoute.js"; import monitorRouter from "./routes/monitorRoute.js"; @@ -45,8 +44,9 @@ import NotificationService from "./service/notificationService.js"; import db from "./db/mongo/MongoDB.js"; const SERVICE_NAME = "Server"; +const SHUTDOWN_TIMEOUT = 0; -let cleaningUp = false; +let isShuttingDown = false; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -113,11 +113,11 @@ const startApp = async () => { // Create services await db.connect(); - app.listen(PORT, () => { + const server = app.listen(PORT, () => { logger.info({ message: `server started on port:${PORT}` }); }); - const settingsService = new SettingsService(AppSettings); + const settingsService = new SettingsService(AppSettings); await settingsService.loadSettings(); const emailService = new EmailService( settingsService, @@ -142,30 +142,39 @@ const startApp = async () => { Worker ); - const cleanup = async () => { - if (cleaningUp) { - logger.warn({ message: "Already cleaning up" }); + const shutdown = async () => { + if (isShuttingDown) { return; } - cleaningUp = true; + isShuttingDown = true; + logger.info({ message: "Attempting graceful shutdown" }); + setTimeout(() => { + logger.error({ + message: "Could not shut down in time, forcing shutdown", + service: SERVICE_NAME, + method: "shutdown", + }); + process.exit(1); + }, SHUTDOWN_TIMEOUT); try { - logger.info({ message: "shutting down gracefully" }); + server.close(); await jobQueue.obliterate(); await db.disconnect(); - logger.info({ message: "shut down gracefully" }); + logger.info({ message: "Graceful shutdown complete" }); + process.exit(0); } catch (error) { logger.error({ message: error.message, service: SERVICE_NAME, - method: "cleanup", + method: "shutdown", stack: error.stack, }); } - process.exit(0); }; - process.on("SIGUSR2", cleanup); - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); + + process.on("SIGUSR2", shutdown); + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); }; startApp().catch((error) => { diff --git a/Server/openapi.json b/Server/openapi.json index 9c3e6d062..5d2d512e6 100644 --- a/Server/openapi.json +++ b/Server/openapi.json @@ -22,16 +22,12 @@ "variables": { "PORT": { "description": "API Port", - "enum": [ - "5000" - ], + "enum": ["5000"], "default": "5000" }, "API_PATH": { "description": "API Base Path", - "enum": [ - "api/v1" - ], + "enum": ["api/v1"], "default": "api/v1" } } @@ -42,9 +38,7 @@ "variables": { "API_PATH": { "description": "API Base Path", - "enum": [ - "api/v1" - ], + "enum": ["api/v1"], "default": "api/v1" } } @@ -55,16 +49,12 @@ "variables": { "PORT": { "description": "API Port", - "enum": [ - "5000" - ], + "enum": ["5000"], "default": "5000" }, "API_PATH": { "description": "API Base Path", - "enum": [ - "api/v1" - ], + "enum": ["api/v1"], "default": "api/v1" } } @@ -99,9 +89,7 @@ "paths": { "/auth/register": { "post": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Register a new user", "requestBody": { "content": { @@ -137,23 +125,8 @@ }, "role": { "type": "array", - "enum": [ - [ - "user" - ], - [ - "admin" - ], - [ - "superadmin" - ], - [ - "Demo" - ] - ], - "default": [ - "superadmin" - ] + "enum": [["user"], ["admin"], ["superadmin"], ["Demo"]], + "default": ["superadmin"] }, "teamId": { "type": "string", @@ -200,19 +173,14 @@ }, "/auth/login": { "post": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Login with credentials", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": [ - "email", - "password" - ], + "required": ["email", "password"], "properties": { "email": { "type": "string", @@ -263,9 +231,7 @@ }, "/auth/refresh": { "post": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Generates a new auth token if the refresh token is valid.", "requestBody": { "content": { @@ -334,9 +300,7 @@ }, "/auth/user/{userId}": { "put": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Change user information", "parameters": [ { @@ -397,9 +361,7 @@ ] }, "delete": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Delete user", "parameters": [ { @@ -452,9 +414,7 @@ }, "/auth/users/superadmin": { "get": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Checks to see if an admin account exists", "responses": { "200": { @@ -497,9 +457,7 @@ }, "/auth/users": { "get": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Get all users", "responses": { "200": { @@ -542,18 +500,14 @@ }, "/auth/recovery/request": { "post": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Request a recovery token", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": [ - "email" - ], + "required": ["email"], "properties": { "email": { "type": "string", @@ -600,18 +554,14 @@ }, "/auth/recovery/validate": { "post": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Validate recovery token", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": [ - "recoveryToken" - ], + "required": ["recoveryToken"], "properties": { "recoveryToken": { "type": "string" @@ -657,19 +607,14 @@ }, "/auth/recovery/reset": { "post": { - "tags": [ - "auth" - ], + "tags": ["auth"], "description": "Password reset", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": [ - "recoveryToken", - "password" - ], + "required": ["recoveryToken", "password"], "properties": { "recoveryToken": { "type": "string" @@ -718,19 +663,14 @@ }, "/invite": { "post": { - "tags": [ - "invite" - ], + "tags": ["invite"], "description": "Request an invitation", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": [ - "email", - "role" - ], + "required": ["email", "role"], "properties": { "email": { "type": "string" @@ -784,18 +724,14 @@ }, "/invite/verify": { "post": { - "tags": [ - "invite" - ], + "tags": ["invite"], "description": "Request an invitation", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "required": [ - "token" - ], + "required": ["token"], "properties": { "token": { "type": "string" @@ -846,9 +782,7 @@ }, "/monitors": { "get": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Get all monitors", "responses": { "200": { @@ -889,9 +823,7 @@ ] }, "post": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Create a new monitor", "requestBody": { "content": { @@ -941,9 +873,7 @@ ] }, "delete": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Delete all monitors", "responses": { "200": { @@ -984,11 +914,42 @@ ] } }, + "/monitors/uptime": { + "get": { + "tags": ["monitors"], + "description": "Get all monitors with uptime stats for 1, 7, 30, and 90 days", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/monitors/resolution/url": { "get": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Check DNS resolution for a given URL", "parameters": [ { @@ -1053,9 +1014,7 @@ }, "/monitors/{monitorId}": { "get": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Get monitor by id", "parameters": [ { @@ -1106,9 +1065,7 @@ ] }, "put": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Update monitor by id", "parameters": [ { @@ -1168,9 +1125,7 @@ ] }, "delete": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Delete monitor by id", "parameters": [ { @@ -1223,9 +1178,7 @@ }, "/monitors/stats/{monitorId}": { "get": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Get monitor stats", "parameters": [ { @@ -1278,9 +1231,7 @@ }, "/monitors/certificate/{monitorId}": { "get": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Get monitor certificate", "parameters": [ { @@ -1333,9 +1284,7 @@ }, "/monitors/team/summary/{teamId}": { "get": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Get monitors and summary by teamId", "parameters": [ { @@ -1352,11 +1301,7 @@ "required": false, "schema": { "type": "array", - "enum": [ - "http", - "ping", - "pagespeed" - ] + "enum": ["http", "ping", "pagespeed"] } } ], @@ -1401,9 +1346,7 @@ }, "/monitors/team/{teamId}": { "get": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Get monitors by teamId", "parameters": [ { @@ -1430,10 +1373,7 @@ "required": false, "schema": { "type": "string", - "enum": [ - "asc", - "desc" - ] + "enum": ["asc", "desc"] } }, { @@ -1452,11 +1392,7 @@ "required": false, "schema": { "type": "string", - "enum": [ - "http", - "ping", - "pagespeed" - ] + "enum": ["http", "ping", "pagespeed"] } }, { @@ -1500,11 +1436,7 @@ "required": false, "schema": { "type": "string", - "enum": [ - "http", - "ping", - "pagespeed" - ] + "enum": ["http", "ping", "pagespeed"] } } ], @@ -1549,9 +1481,7 @@ }, "/monitors/pause/{monitorId}": { "post": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Pause monitor", "parameters": [ { @@ -1604,9 +1534,7 @@ }, "/monitors/demo": { "post": { - "tags": [ - "monitors" - ], + "tags": ["monitors"], "description": "Create a demo monitor", "requestBody": { "content": { @@ -1658,9 +1586,7 @@ }, "/checks/{monitorId}": { "get": { - "tags": [ - "checks" - ], + "tags": ["checks"], "description": "Get all checks for a monitor", "parameters": [ { @@ -1711,9 +1637,7 @@ ] }, "post": { - "tags": [ - "checks" - ], + "tags": ["checks"], "description": "Create a new check", "parameters": [ { @@ -1773,9 +1697,7 @@ ] }, "delete": { - "tags": [ - "checks" - ], + "tags": ["checks"], "description": "Delete all checks for a monitor", "parameters": [ { @@ -1828,9 +1750,7 @@ }, "/checks/team/{teamId}": { "get": { - "tags": [ - "checks" - ], + "tags": ["checks"], "description": "Get all checks for a team", "parameters": [ { @@ -1881,9 +1801,7 @@ ] }, "delete": { - "tags": [ - "checks" - ], + "tags": ["checks"], "description": "Delete all checks for a team", "parameters": [ { @@ -1936,9 +1854,7 @@ }, "/checks/team/ttl": { "put": { - "tags": [ - "checks" - ], + "tags": ["checks"], "description": "Update check TTL", "requestBody": { "content": { @@ -1990,9 +1906,7 @@ }, "/maintenance-window/monitor/{monitorId}": { "get": { - "tags": [ - "maintenance-window" - ], + "tags": ["maintenance-window"], "description": "Get maintenance window for monitor", "parameters": [ { @@ -2043,9 +1957,7 @@ ] }, "post": { - "tags": [ - "maintenance-window" - ], + "tags": ["maintenance-window"], "description": "Create maintenance window for monitor", "parameters": [ { @@ -2107,9 +2019,7 @@ }, "/maintenance-window/user/{userId}": { "get": { - "tags": [ - "maintenance-window" - ], + "tags": ["maintenance-window"], "description": "Get maintenance window for user", "parameters": [ { @@ -2162,9 +2072,7 @@ }, "/queue/jobs": { "get": { - "tags": [ - "queue" - ], + "tags": ["queue"], "description": "Get all jobs in queue", "responses": { "200": { @@ -2205,9 +2113,7 @@ ] }, "post": { - "tags": [ - "queue" - ], + "tags": ["queue"], "description": "Create a new job. Useful for testing scaling workers", "responses": { "200": { @@ -2250,9 +2156,7 @@ }, "/queue/metrics": { "get": { - "tags": [ - "queue" - ], + "tags": ["queue"], "description": "Get queue metrics", "responses": { "200": { @@ -2295,9 +2199,7 @@ }, "/queue/obliterate": { "post": { - "tags": [ - "queue" - ], + "tags": ["queue"], "description": "Obliterate job queue", "responses": { "200": { @@ -2377,14 +2279,7 @@ }, "UserUpdateRequest": { "type": "object", - "required": [ - "firstName", - "lastName", - "email", - "password", - "role", - "teamId" - ], + "required": ["firstName", "lastName", "email", "password", "role", "teamId"], "properties": { "firstName": { "type": "string" @@ -2406,23 +2301,8 @@ }, "role": { "type": "array", - "enum": [ - [ - "user" - ], - [ - "admin" - ], - [ - "superadmin" - ], - [ - "Demo" - ] - ], - "default": [ - "superadmin" - ] + "enum": [["user"], ["admin"], ["superadmin"], ["Demo"]], + "default": ["superadmin"] }, "deleteProfileImage": { "type": "boolean" @@ -2431,14 +2311,7 @@ }, "CreateMonitorBody": { "type": "object", - "required": [ - "userId", - "teamId", - "name", - "description", - "type", - "url" - ], + "required": ["userId", "teamId", "name", "description", "type", "url"], "properties": { "_id": { "type": "string" @@ -2457,11 +2330,7 @@ }, "type": { "type": "string", - "enum": [ - "http", - "ping", - "pagespeed" - ] + "enum": ["http", "ping", "pagespeed"] }, "url": { "type": "string" @@ -2502,13 +2371,7 @@ }, "CreateCheckBody": { "type": "object", - "required": [ - "monitorId", - "status", - "responseTime", - "statusCode", - "message" - ], + "required": ["monitorId", "status", "responseTime", "statusCode", "message"], "properties": { "monitorId": { "type": "string" @@ -2529,9 +2392,7 @@ }, "UpdateCheckTTLBody": { "type": "object", - "required": [ - "ttl" - ], + "required": ["ttl"], "properties": { "ttl": { "type": "integer" @@ -2540,13 +2401,7 @@ }, "CreateMaintenanceWindowBody": { "type": "object", - "required": [ - "userId", - "active", - "oneTime", - "start", - "end" - ], + "required": ["userId", "active", "oneTime", "start", "end"], "properties": { "userId": { "type": "string" diff --git a/Server/package-lock.json b/Server/package-lock.json index 00c38c029..53278a0e6 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -12,7 +12,7 @@ "@sendgrid/mail": "^8.1.3", "axios": "^1.7.2", "bcrypt": "^5.1.1", - "bullmq": "5.25.0", + "bullmq": "5.25.4", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -1164,9 +1164,9 @@ "license": "MIT" }, "node_modules/bullmq": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.25.0.tgz", - "integrity": "sha512-QBbtabDUgdztalbYbrCc5NFwrRUKOZyiAkVFPhdrBGFsdraPq5SrXx6WP7U1stKj3hYYp1IuW+n3wuksYGITvw==", + "version": "5.25.4", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.25.4.tgz", + "integrity": "sha512-f9M5qfFOg9hdoMWmux9x9rZm9ZUPTMFfdDMO2zRsi7IOzgvZ0UxB6oTk77PlC9YSDYoufAgBw82xU1nwvnsKSA==", "license": "MIT", "dependencies": { "cron-parser": "^4.6.0", @@ -3467,9 +3467,10 @@ } }, "node_modules/logform": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", - "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", @@ -4369,9 +4370,9 @@ } }, "node_modules/mongoose": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.8.0.tgz", - "integrity": "sha512-KluvgwnQB1GPOYZZXUHJRjS1TW6xxwTlf/YgjWExuuNanIe3W7VcR7dDXQVCIRk8L7NYge8EnoTcu2grWtN+XQ==", + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.8.1.tgz", + "integrity": "sha512-l7DgeY1szT98+EKU8GYnga5WnyatAu+kOQ2VlVX1Mxif6A0Umt0YkSiksCiyGxzx8SPhGe9a53ND1GD4yVDrPA==", "license": "MIT", "dependencies": { "bson": "^6.7.0", @@ -6673,34 +6674,35 @@ } }, "node_modules/winston": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.16.0.tgz", - "integrity": "sha512-xz7+cyGN5M+4CmmD4Npq1/4T+UZaz7HaeTlAruFUTjk79CNMq+P6H30vlE4z0qfqJ01VHYQwd7OZo03nYm/+lg==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", - "logform": "^2.6.0", + "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", - "winston-transport": "^4.7.0" + "winston-transport": "^4.9.0" }, "engines": { "node": ">= 12.0.0" } }, "node_modules/winston-transport": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.0.tgz", - "integrity": "sha512-ajBj65K5I7denzer2IYW6+2bNIVqLGDHqDw3Ow8Ohh+vdW+rv4MZ6eiDvHoKhfJFZ2auyN8byXieDDJ96ViONg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", "dependencies": { - "logform": "^2.3.2", - "readable-stream": "^3.6.0", + "logform": "^2.7.0", + "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" }, "engines": { diff --git a/Server/package.json b/Server/package.json index 27e6acd4e..8e822c024 100644 --- a/Server/package.json +++ b/Server/package.json @@ -15,7 +15,7 @@ "@sendgrid/mail": "^8.1.3", "axios": "^1.7.2", "bcrypt": "^5.1.1", - "bullmq": "5.25.0", + "bullmq": "5.25.4", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/Server/routes/monitorRoute.js b/Server/routes/monitorRoute.js index f306a5146..056d81ed6 100644 --- a/Server/routes/monitorRoute.js +++ b/Server/routes/monitorRoute.js @@ -1,18 +1,19 @@ import { Router } from "express"; import { - getAllMonitors, - getMonitorStatsById, - getMonitorCertificate, - getMonitorById, - getMonitorsAndSummaryByTeamId, - getMonitorsByTeamId, - createMonitor, - checkEndpointResolution, - deleteMonitor, - deleteAllMonitors, - editMonitor, - pauseMonitor, - addDemoMonitors, + getAllMonitors, + getAllMonitorsWithUptimeStats, + getMonitorStatsById, + getMonitorCertificate, + getMonitorById, + getMonitorsAndSummaryByTeamId, + getMonitorsByTeamId, + createMonitor, + checkEndpointResolution, + deleteMonitor, + deleteAllMonitors, + editMonitor, + pauseMonitor, + addDemoMonitors, } from "../controllers/monitorController.js"; import { isAllowed } from "../middleware/isAllowed.js"; import { fetchMonitorCertificate } from "../controllers/controllerUtils.js"; @@ -20,26 +21,23 @@ import { fetchMonitorCertificate } from "../controllers/controllerUtils.js"; const router = Router(); router.get("/", getAllMonitors); +router.get("/uptime", getAllMonitorsWithUptimeStats); router.get("/stats/:monitorId", getMonitorStatsById); router.get("/certificate/:monitorId", (req, res, next) => { - getMonitorCertificate(req, res, next, fetchMonitorCertificate); + getMonitorCertificate(req, res, next, fetchMonitorCertificate); }); router.get("/:monitorId", getMonitorById); router.get("/team/summary/:teamId", getMonitorsAndSummaryByTeamId); router.get("/team/:teamId", getMonitorsByTeamId); router.get( - "/resolution/url", - isAllowed(["admin", "superadmin"]), - checkEndpointResolution -) - -router.delete( - "/:monitorId", - isAllowed(["admin", "superadmin"]), - deleteMonitor + "/resolution/url", + isAllowed(["admin", "superadmin"]), + checkEndpointResolution ); +router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), deleteMonitor); + router.post("/", isAllowed(["admin", "superadmin"]), createMonitor); router.put("/:monitorId", isAllowed(["admin", "superadmin"]), editMonitor); diff --git a/Server/service/jobQueue.js b/Server/service/jobQueue.js index 6d46052f1..aaab725e5 100644 --- a/Server/service/jobQueue.js +++ b/Server/service/jobQueue.js @@ -36,10 +36,10 @@ class JobQueue { host: redisHost, port: redisPort, }; - this.connection = connection; this.queue = new Queue(QUEUE_NAME, { connection, }); + this.connection = connection; this.workers = []; this.db = null; this.networkService = null; @@ -406,9 +406,6 @@ class JobQueue { delayed: await this.queue.getDelayedCount(), repeatableJobs: (await this.queue.getRepeatableJobs()).length, }; - this.logger.info({ - message: metrics, - }); return metrics; } catch (error) { this.logger.error({ @@ -424,17 +421,20 @@ class JobQueue { * @async * @returns {Promise} - Returns true if obliteration is successful */ + async obliterate() { try { - let metrics = await this.getMetrics(); - this.logger.info({ message: metrics }); + this.logger.info({ message: "Attempting to obliterate job queue..." }); await this.queue.pause(); const jobs = await this.getJobs(); + // Remove all repeatable jobs for (const job of jobs) { await this.queue.removeRepeatableByKey(job.key); await this.queue.remove(job.id); } + + // Close workers await Promise.all( this.workers.map(async (worker) => { await worker.close(); @@ -442,9 +442,13 @@ class JobQueue { ); await this.queue.obliterate(); - metrics = await this.getMetrics(); - this.logger.info({ message: metrics }); - this.logger.info({ message: successMessages.JOB_QUEUE_OBLITERATE }); + const metrics = await this.getMetrics(); + this.logger.info({ + message: successMessages.JOB_QUEUE_OBLITERATE, + service: SERVICE_NAME, + method: "obliterate", + details: metrics, + }); return true; } catch (error) { error.service === undefined ? (error.service = SERVICE_NAME) : null; diff --git a/Server/tests/controllers/monitorController.test.js b/Server/tests/controllers/monitorController.test.js index 0d4deb742..1074c35b1 100644 --- a/Server/tests/controllers/monitorController.test.js +++ b/Server/tests/controllers/monitorController.test.js @@ -1,5 +1,6 @@ import { getAllMonitors, + getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorCertificate, getMonitorById, @@ -61,6 +62,47 @@ describe("Monitor Controller - getAllMonitors", () => { ).to.be.true; }); }); +describe("Monitor Controller - getAllMonitorsWithUptimeStats", () => { + let req, res, next; + beforeEach(() => { + req = { + params: {}, + query: {}, + body: {}, + db: { + getAllMonitorsWithUptimeStats: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + afterEach(() => { + sinon.restore(); + }); + it("should reject with an error if DB operations fail", async () => { + req.db.getAllMonitorsWithUptimeStats.throws(new Error("DB error")); + await getAllMonitorsWithUptimeStats(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed", async () => { + const data = [{ monitor: "data" }]; + req.db.getAllMonitorsWithUptimeStats.returns(data); + await getAllMonitorsWithUptimeStats(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_ALL, + data: data, + }) + ).to.be.true; + }); +}); describe("Monitor Controller - getMonitorStatsById", () => { let req, res, next; diff --git a/Server/tests/db/checkModule.test.js b/Server/tests/db/checkModule.test.js new file mode 100644 index 000000000..96854b3f3 --- /dev/null +++ b/Server/tests/db/checkModule.test.js @@ -0,0 +1,565 @@ +import sinon from "sinon"; +import { + createCheck, + getChecksCount, + getChecks, + getTeamChecks, + deleteChecks, + deleteChecksByTeamId, + updateChecksTTL, +} from "../../db/mongo/modules/checkModule.js"; +import Check from "../../db/models/Check.js"; +import Monitor from "../../db/models/Monitor.js"; +import User from "../../db/models/User.js"; +import logger from "../../utils/logger.js"; + +describe("checkModule", () => { + describe("createCheck", () => { + let checkCountDocumentsStub, checkSaveStub, monitorFindByIdStub, monitorSaveStub; + const mockMonitor = { + _id: "123", + uptimePercentage: 0.5, + status: true, + save: () => this, + }; + const mockCheck = { active: true }; + beforeEach(() => { + checkSaveStub = sinon.stub(Check.prototype, "save"); + checkCountDocumentsStub = sinon.stub(Check, "countDocuments"); + monitorFindByIdStub = sinon.stub(Monitor, "findById"); + monitorSaveStub = sinon.stub(Monitor.prototype, "save"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return undefined early if no monitor is found", async () => { + monitorFindByIdStub.returns(null); + const check = await createCheck({ monitorId: "123" }); + expect(check).to.be.undefined; + }); + + it("should return a check", async () => { + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: true }); + expect(check).to.deep.equal(mockCheck); + }); + it("should return a check if status is down", async () => { + mockMonitor.status = false; + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: false }); + expect(check).to.deep.equal(mockCheck); + }); + it("should return a check if uptimePercentage is undefined", async () => { + mockMonitor.uptimePercentage = undefined; + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: true }); + expect(check).to.deep.equal(mockCheck); + }); + it("should return a check if uptimePercentage is undefined and status is down", async () => { + mockMonitor.uptimePercentage = undefined; + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: false }); + expect(check).to.deep.equal(mockCheck); + }); + it("should monitor save error", async () => { + const err = new Error("Save Error"); + monitorSaveStub.throws(err); + try { + await createCheck({ monitorId: "123" }); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + it("should handle errors", async () => { + const err = new Error("DB Error"); + checkCountDocumentsStub.throws(err); + try { + await createCheck({ monitorId: "123" }); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + describe("getChecksCount", () => { + let checkCountDocumentStub; + + beforeEach(() => { + checkCountDocumentStub = sinon.stub(Check, "countDocuments"); + }); + + afterEach(() => { + checkCountDocumentStub.restore(); + }); + + it("should return count with basic monitorId query", async () => { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + checkCountDocumentStub.resolves(5); + + const result = await getChecksCount(req); + + expect(result).to.equal(5); + expect(checkCountDocumentStub.calledOnce).to.be.true; + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + }); + }); + + it("should include dateRange in query when provided", async () => { + const req = { + params: { monitorId: "test123" }, + query: { dateRange: "day" }, + }; + checkCountDocumentStub.resolves(3); + + const result = await getChecksCount(req); + + expect(result).to.equal(3); + expect(checkCountDocumentStub.firstCall.args[0]).to.have.property("createdAt"); + expect(checkCountDocumentStub.firstCall.args[0].createdAt).to.have.property("$gte"); + }); + + it('should handle "all" filter correctly', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "all" }, + }; + checkCountDocumentStub.resolves(2); + + const result = await getChecksCount(req); + + expect(result).to.equal(2); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + }); + }); + + it('should handle "down" filter correctly', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "down" }, + }; + checkCountDocumentStub.resolves(2); + + const result = await getChecksCount(req); + + expect(result).to.equal(2); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + }); + }); + + it('should handle "resolve" filter correctly', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "resolve" }, + }; + checkCountDocumentStub.resolves(1); + + const result = await getChecksCount(req); + + expect(result).to.equal(1); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + statusCode: 5000, + }); + }); + it("should handle unknown filter correctly", async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "unknown" }, + }; + checkCountDocumentStub.resolves(1); + + const result = await getChecksCount(req); + + expect(result).to.equal(1); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + }); + }); + + it("should combine dateRange and filter in query", async () => { + const req = { + params: { monitorId: "test123" }, + query: { + dateRange: "week", + filter: "down", + }, + }; + checkCountDocumentStub.resolves(4); + + const result = await getChecksCount(req); + + expect(result).to.equal(4); + expect(checkCountDocumentStub.firstCall.args[0]).to.have.all.keys( + "monitorId", + "createdAt", + "status" + ); + }); + }); + describe("getChecks", () => { + let checkFindStub, monitorFindStub; + + beforeEach(() => { + checkFindStub = sinon.stub(Check, "find").returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([{ id: 1 }, { id: 2 }]), + }), + }), + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return checks with basic monitorId query", async () => { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + it("should return checks with limit query", async () => { + const req = { + params: { monitorId: "test123" }, + query: { limit: 10 }, + }; + + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle pagination correctly", async () => { + const req = { + params: { monitorId: "test123" }, + query: { + page: 2, + rowsPerPage: 10, + }, + }; + + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle dateRange filter", async () => { + const req = { + params: { monitorId: "test123" }, + query: { dateRange: "week" }, + }; + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it('should handle "all" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "all" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + it('should handle "down" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "down" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it('should handle "resolve" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "resolve" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + it('should handle "unknown" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "unknown" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle ascending sort order", async () => { + const req = { + params: { monitorId: "test123" }, + query: { sortOrder: "asc" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle error case", async () => { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + + checkFindStub.throws(new Error("Database error")); + + try { + await getChecks(req); + } catch (error) { + expect(error.message).to.equal("Database error"); + expect(error.service).to.equal("checkModule"); + expect(error.method).to.equal("getChecks"); + } + }); + }); + describe("getTeamChecks", () => { + let checkFindStub, checkCountDocumentsStub, monitorFindStub; + const mockMonitors = [{ _id: "123" }]; + beforeEach(() => { + monitorFindStub = sinon.stub(Monitor, "find").returns({ + select: sinon.stub().returns(mockMonitors), + }); + checkCountDocumentsStub = sinon.stub(Check, "countDocuments").returns(2); + checkFindStub = sinon.stub(Check, "find").returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns({ + select: sinon.stub().returns([{ id: 1 }, { id: 2 }]), + }), + }), + }), + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return checks with basic monitorId query", async () => { + const req = { + params: { teamId: "test123" }, + query: {}, + }; + + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle pagination correctly", async () => { + const req = { + params: { monitorId: "test123" }, + query: { limit: 1, page: 2, rowsPerPage: 10 }, + }; + + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle dateRange filter", async () => { + const req = { + params: { monitorId: "test123" }, + query: { dateRange: "week" }, + }; + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it('should handle "all" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "all" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + it('should handle "down" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "down" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it('should handle "resolve" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "resolve" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + it('should handle "unknown" filter', async () => { + const req = { + params: { monitorId: "test123" }, + query: { filter: "unknown" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle ascending sort order", async () => { + const req = { + params: { monitorId: "test123" }, + query: { sortOrder: "asc" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle error case", async () => { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + + checkFindStub.throws(new Error("Database error")); + + try { + await getTeamChecks(req); + } catch (error) { + expect(error.message).to.equal("Database error"); + expect(error.service).to.equal("checkModule"); + expect(error.method).to.equal("getTeamChecks"); + } + }); + }); + describe("deleteChecks", () => { + let checkDeleteManyStub; + beforeEach(() => { + checkDeleteManyStub = sinon.stub(Check, "deleteMany").resolves({ deletedCount: 1 }); + }); + afterEach(() => { + sinon.restore(); + }); + + it("should return a value if a check is deleted", async () => { + const result = await deleteChecks("123"); + expect(result).to.equal(1); + }); + it("should handle an error", async () => { + checkDeleteManyStub.throws(new Error("Database error")); + try { + await deleteChecks("123"); + } catch (error) { + expect(error.message).to.equal("Database error"); + expect(error.method).to.equal("deleteChecks"); + } + }); + }); + describe("deleteChecksByTeamId", () => { + let mockMonitors = [{ _id: 123, save: () => this }]; + let monitorFindStub, monitorSaveStub, checkDeleteManyStub; + beforeEach(() => { + monitorSaveStub = sinon.stub(Monitor.prototype, "save"); + monitorFindStub = sinon.stub(Monitor, "find").returns(mockMonitors); + checkDeleteManyStub = sinon.stub(Check, "deleteMany").resolves({ deletedCount: 1 }); + }); + afterEach(() => { + sinon.restore(); + }); + it("should return a deleted count", async () => { + const result = await deleteChecksByTeamId("123"); + expect(result).to.equal(1); + }); + it("should handle errors", async () => { + const err = new Error("DB Error"); + monitorFindStub.throws(err); + try { + const result = await deleteChecksByTeamId("123"); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + describe("updateChecksTTL", () => { + let userUpdateManyStub; + let loggerStub; + beforeEach(() => { + loggerStub = sinon.stub(logger, "error"); + userUpdateManyStub = sinon.stub(User, "updateMany"); + Check.collection = { dropIndex: sinon.stub(), createIndex: sinon.stub() }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return undefined", async () => { + const result = await updateChecksTTL("123", 10); + expect(result).to.be.undefined; + }); + + it("should log an error if dropIndex throws an error", async () => { + const err = new Error("Drop Index Error"); + Check.collection.dropIndex.throws(err); + await updateChecksTTL("123", 10); + expect(loggerStub.calledOnce).to.be.true; + expect(loggerStub.firstCall.args[0].message).to.equal(err.message); + }); + + it("should throw an error if createIndex throws an error", async () => { + const err = new Error("Create Index Error"); + Check.collection.createIndex.throws(err); + try { + await updateChecksTTL("123", 10); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + it("should throw an error if User.updateMany throws an error", async () => { + const err = new Error("Update Many Error"); + userUpdateManyStub.throws(err); + try { + await updateChecksTTL("123", 10); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/Server/tests/db/monitorModule.test.js b/Server/tests/db/monitorModule.test.js new file mode 100644 index 000000000..558dcb4d2 --- /dev/null +++ b/Server/tests/db/monitorModule.test.js @@ -0,0 +1,1770 @@ +import sinon from "sinon"; +import Monitor from "../../db/models/Monitor.js"; +import Check from "../../db/models/Check.js"; +import Notification from "../../db/models/Notification.js"; + +import { errorMessages } from "../../utils/messages.js"; +import { + getAllMonitors, + getAllMonitorsWithUptimeStats, + getMonitorStatsById, + getMonitorById, + getMonitorsAndSummaryByTeamId, + getMonitorsByTeamId, + createMonitor, + deleteMonitor, + deleteAllMonitors, + deleteMonitorsByUserId, + editMonitor, + addDemoMonitors, + calculateUptimeDuration, + getLastChecked, + getLatestResponseTime, + getAverageResponseTime, + getUptimePercentage, + getIncidents, + getMonitorChecks, + processChecksForDisplay, + groupChecksByTime, + calculateGroupStats, +} from "../../db/mongo/modules/monitorModule.js"; + +describe("monitorModule", () => { + let monitorFindStub, + monitorFindByIdStub, + monitorFindByIdAndUpdateStub, + monitorFindByIdAndDeleteStub, + monitorDeleteManyStub, + monitorCountStub, + monitorInsertManyStub, + checkFindStub; + beforeEach(() => { + monitorFindStub = sinon.stub(Monitor, "find"); + monitorFindByIdStub = sinon.stub(Monitor, "findById"); + monitorFindByIdAndUpdateStub = sinon.stub(Monitor, "findByIdAndUpdate"); + monitorFindByIdAndDeleteStub = sinon.stub(Monitor, "findByIdAndDelete"); + monitorDeleteManyStub = sinon.stub(Monitor, "deleteMany"); + monitorCountStub = sinon.stub(Monitor, "countDocuments"); + + monitorInsertManyStub = sinon.stub(Monitor, "insertMany"); + checkFindStub = sinon.stub(Check, "find").returns({ + sort: sinon.stub(), + }); + }); + afterEach(() => { + sinon.restore(); + }); + + describe("getAllMonitors", () => { + it("should return all monitors", async () => { + const mockMonitors = [ + { _id: "1", name: "Monitor 1", url: "test1.com" }, + { _id: "2", name: "Monitor 2", url: "test2.com" }, + ]; + monitorFindStub.returns(mockMonitors); + const result = await getAllMonitors(); + + expect(result).to.deep.equal(mockMonitors); + expect(monitorFindStub.calledOnce).to.be.true; + expect(monitorFindStub.firstCall.args).to.deep.equal([]); + }); + it("should handle empty results", async () => { + monitorFindStub.returns([]); + const result = await getAllMonitors(); + expect(result).to.be.an("array").that.is.empty; + }); + it("should throw error when database fails", async () => { + // Arrange + const error = new Error("Database error"); + error.service = "MonitorModule"; + error.method = "getAllMonitors"; + monitorFindStub.rejects(error); + // Act & Assert + try { + await getAllMonitors(); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).to.equal(error); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("getAllMonitors"); + } + }); + }); + + describe("getAllMonitorsWithUptimeStats", () => { + it("should return monitors with uptime stats for different time periods", async () => { + // Mock data + const mockMonitors = [ + { + _id: "monitor1", + type: "http", + toObject: () => ({ + _id: "monitor1", + type: "http", + name: "Test Monitor", + }), + }, + ]; + + const mockChecks = [ + { status: true }, + { status: true }, + { status: false }, + { status: true }, + ]; + + monitorFindStub.resolves(mockMonitors); + checkFindStub.resolves(mockChecks); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(1); + + const monitor = result[0]; + expect(monitor).to.have.property("_id", "monitor1"); + expect(monitor).to.have.property("name", "Test Monitor"); + + // Check uptime percentages exist for all time periods + expect(monitor).to.have.property("1"); + expect(monitor).to.have.property("7"); + expect(monitor).to.have.property("30"); + expect(monitor).to.have.property("90"); + + // Verify uptime percentage calculation (3 successful out of 4 = 75%) + expect(monitor["1"]).to.equal(75); + expect(monitor["7"]).to.equal(75); + expect(monitor["30"]).to.equal(75); + expect(monitor["90"]).to.equal(75); + }); + + it("should handle errors appropriately", async () => { + // Setup stub to throw error + monitorFindStub.rejects(new Error("Database error")); + + try { + await getAllMonitorsWithUptimeStats(); + } catch (error) { + expect(error).to.be.an("error"); + expect(error.message).to.equal("Database error"); + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getAllMonitorsWithUptimeStats"); + } + }); + it("should handle empty monitor list", async () => { + monitorFindStub.resolves([]); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(0); + }); + + it("should handle monitor with no checks", async () => { + const mockMonitors = [ + { + _id: "monitor1", + type: "http", + toObject: () => ({ + _id: "monitor1", + type: "http", + name: "Test Monitor", + }), + }, + ]; + + monitorFindStub.resolves(mockMonitors); + checkFindStub.resolves([]); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result[0]).to.have.property("1", 0); + expect(result[0]).to.have.property("7", 0); + expect(result[0]).to.have.property("30", 0); + expect(result[0]).to.have.property("90", 0); + }); + }); + + describe("calculateUptimeDuration", () => { + let clock; + const NOW = new Date("2024-01-01T12:00:00Z").getTime(); + + beforeEach(() => { + // Fix the current time + clock = sinon.useFakeTimers(NOW); + }); + + afterEach(() => { + clock.restore(); + }); + + it("should return 0 when checks array is empty", () => { + expect(calculateUptimeDuration([])).to.equal(0); + }); + + it("should return 0 when checks array is null", () => { + expect(calculateUptimeDuration(null)).to.equal(0); + }); + + it("should calculate uptime from last down check to most recent check", () => { + const checks = [ + { status: true, createdAt: "2024-01-01T11:00:00Z" }, // Most recent + { status: true, createdAt: "2024-01-01T10:00:00Z" }, + { status: false, createdAt: "2024-01-01T09:00:00Z" }, // Last down + { status: true, createdAt: "2024-01-01T08:00:00Z" }, + ]; + + // Expected: 2 hours (from 09:00 to 11:00) = 7200000ms + expect(calculateUptimeDuration(checks)).to.equal(7200000); + }); + + it("should calculate uptime from first check when no down checks exist", () => { + const checks = [ + { status: true, createdAt: "2024-01-01T11:00:00Z" }, + { status: true, createdAt: "2024-01-01T10:00:00Z" }, + { status: true, createdAt: "2024-01-01T09:00:00Z" }, + ]; + + // Expected: Current time (12:00) - First check (09:00) = 3 hours = 10800000ms + expect(calculateUptimeDuration(checks)).to.equal(10800000); + }); + }); + + describe("getLastChecked", () => { + let clock; + const NOW = new Date("2024-01-01T12:00:00Z").getTime(); + + beforeEach(() => { + // Fix the current time + clock = sinon.useFakeTimers(NOW); + }); + + afterEach(() => { + clock.restore(); + }); + + it("should return 0 when checks array is empty", () => { + expect(getLastChecked([])).to.equal(0); + }); + + it("should return 0 when checks array is null", () => { + expect(getLastChecked(null)).to.equal(0); + }); + + it("should return time difference between now and most recent check", () => { + const checks = [ + { createdAt: "2024-01-01T11:30:00Z" }, // 30 minutes ago + { createdAt: "2024-01-01T11:00:00Z" }, + { createdAt: "2024-01-01T10:30:00Z" }, + ]; + + // Expected: 30 minutes = 1800000ms + expect(getLastChecked(checks)).to.equal(1800000); + }); + + it("should handle checks from different days", () => { + const checks = [ + { createdAt: "2023-12-31T12:00:00Z" }, // 24 hours ago + { createdAt: "2023-12-30T12:00:00Z" }, + ]; + + // Expected: 24 hours = 86400000ms + expect(getLastChecked(checks)).to.equal(86400000); + }); + }); + describe("getLatestResponseTime", () => { + it("should return 0 when checks array is empty", () => { + expect(getLatestResponseTime([])).to.equal(0); + }); + + it("should return 0 when checks array is null", () => { + expect(getLatestResponseTime(null)).to.equal(0); + }); + + it("should return response time from most recent check", () => { + const checks = [ + { responseTime: 150, createdAt: "2024-01-01T11:30:00Z" }, // Most recent + { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }, + { responseTime: 250, createdAt: "2024-01-01T10:30:00Z" }, + ]; + + expect(getLatestResponseTime(checks)).to.equal(150); + }); + + it("should handle missing responseTime in checks", () => { + const checks = [ + { createdAt: "2024-01-01T11:30:00Z" }, + { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }, + ]; + + expect(getLatestResponseTime(checks)).to.equal(0); + }); + }); + describe("getAverageResponseTime", () => { + it("should return 0 when checks array is empty", () => { + expect(getAverageResponseTime([])).to.equal(0); + }); + + it("should return 0 when checks array is null", () => { + expect(getAverageResponseTime(null)).to.equal(0); + }); + + it("should calculate average response time from all checks", () => { + const checks = [ + { responseTime: 100, createdAt: "2024-01-01T11:30:00Z" }, + { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }, + { responseTime: 300, createdAt: "2024-01-01T10:30:00Z" }, + ]; + + // Average: (100 + 200 + 300) / 3 = 200 + expect(getAverageResponseTime(checks)).to.equal(200); + }); + + it("should handle missing responseTime in some checks", () => { + const checks = [ + { responseTime: 100, createdAt: "2024-01-01T11:30:00Z" }, + { createdAt: "2024-01-01T11:00:00Z" }, + { responseTime: 300, createdAt: "2024-01-01T10:30:00Z" }, + ]; + + // Average: (100 + 300) / 2 = 200 + expect(getAverageResponseTime(checks)).to.equal(200); + }); + + it("should return 0 when no checks have responseTime", () => { + const checks = [ + { createdAt: "2024-01-01T11:30:00Z" }, + { createdAt: "2024-01-01T11:00:00Z" }, + ]; + + expect(getAverageResponseTime(checks)).to.equal(0); + }); + }); + describe("getUptimePercentage", () => { + it("should return 0 when checks array is empty", () => { + expect(getUptimePercentage([])).to.equal(0); + }); + + it("should return 0 when checks array is null", () => { + expect(getUptimePercentage(null)).to.equal(0); + }); + + it("should return 100 when all checks are up", () => { + const checks = [{ status: true }, { status: true }, { status: true }]; + expect(getUptimePercentage(checks)).to.equal(100); + }); + + it("should return 0 when all checks are down", () => { + const checks = [{ status: false }, { status: false }, { status: false }]; + expect(getUptimePercentage(checks)).to.equal(0); + }); + + it("should calculate correct percentage for mixed status checks", () => { + const checks = [ + { status: true }, + { status: false }, + { status: true }, + { status: true }, + ]; + // 3 up out of 4 total = 75% + expect(getUptimePercentage(checks)).to.equal(75); + }); + + it("should handle undefined status values", () => { + const checks = [{ status: true }, { status: undefined }, { status: true }]; + // 2 up out of 3 total ≈ 66.67% + expect(getUptimePercentage(checks)).to.equal((2 / 3) * 100); + }); + }); + describe("getIncidents", () => { + it("should return 0 when checks array is empty", () => { + expect(getIncidents([])).to.equal(0); + }); + + it("should return 0 when checks array is null", () => { + expect(getIncidents(null)).to.equal(0); + }); + + it("should return 0 when all checks are up", () => { + const checks = [{ status: true }, { status: true }, { status: true }]; + expect(getIncidents(checks)).to.equal(0); + }); + + it("should count all incidents when all checks are down", () => { + const checks = [{ status: false }, { status: false }, { status: false }]; + expect(getIncidents(checks)).to.equal(3); + }); + + it("should count correct number of incidents for mixed status checks", () => { + const checks = [ + { status: true }, + { status: false }, + { status: true }, + { status: false }, + { status: true }, + ]; + expect(getIncidents(checks)).to.equal(2); + }); + + it("should handle undefined status values", () => { + const checks = [ + { status: true }, + { status: undefined }, + { status: false }, + { status: false }, + ]; + // Only counts explicit false values + expect(getIncidents(checks)).to.equal(2); + }); + }); + describe("getMonitorChecks", () => { + let mockModel; + + beforeEach(() => { + // Create a mock model with chainable methods + const mockChecks = [ + { monitorId: "123", createdAt: new Date("2024-01-01") }, + { monitorId: "123", createdAt: new Date("2024-01-02") }, + ]; + + mockModel = { + find: sinon.stub().returns({ + sort: sinon.stub().returns(mockChecks), + }), + }; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return all checks and date-ranged checks", async () => { + // Arrange + const monitorId = "123"; + const dateRange = { + start: new Date("2024-01-01"), + end: new Date("2024-01-02"), + }; + const sortOrder = -1; + + // Act + const result = await getMonitorChecks(monitorId, mockModel, dateRange, sortOrder); + + // Assert + expect(result).to.have.keys(["checksAll", "checksForDateRange"]); + + // Verify find was called with correct parameters + expect(mockModel.find.firstCall.args[0]).to.deep.equal({ monitorId }); + expect(mockModel.find.secondCall.args[0]).to.deep.equal({ + monitorId, + createdAt: { $gte: dateRange.start, $lte: dateRange.end }, + }); + + // Verify sort was called with correct parameters + const sortCalls = mockModel.find().sort.getCalls(); + sortCalls.forEach((call) => { + expect(call.args[0]).to.deep.equal({ createdAt: sortOrder }); + }); + }); + + it("should handle empty results", async () => { + // Arrange + const emptyModel = { + find: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }; + + // Act + const result = await getMonitorChecks( + "123", + emptyModel, + { + start: new Date(), + end: new Date(), + }, + -1 + ); + + // Assert + expect(result.checksAll).to.be.an("array").that.is.empty; + expect(result.checksForDateRange).to.be.an("array").that.is.empty; + }); + + it("should maintain sort order", async () => { + // Arrange + const sortedChecks = [ + { monitorId: "123", createdAt: new Date("2024-01-02") }, + { monitorId: "123", createdAt: new Date("2024-01-01") }, + ]; + + const sortedModel = { + find: sinon.stub().returns({ + sort: sinon.stub().returns(sortedChecks), + }), + }; + + // Act + const result = await getMonitorChecks( + "123", + sortedModel, + { + start: new Date("2024-01-01"), + end: new Date("2024-01-02"), + }, + -1 + ); + + // Assert + expect(result.checksAll[0].createdAt).to.be.greaterThan( + result.checksAll[1].createdAt + ); + expect(result.checksForDateRange[0].createdAt).to.be.greaterThan( + result.checksForDateRange[1].createdAt + ); + }); + }); + + describe("processChecksForDisplay", () => { + let normalizeStub; + + beforeEach(() => { + normalizeStub = sinon.stub(); + }); + + it("should return original checks when numToDisplay is not provided", () => { + const checks = [1, 2, 3, 4, 5]; + const result = processChecksForDisplay(normalizeStub, checks); + expect(result).to.deep.equal(checks); + }); + + it("should return original checks when numToDisplay is greater than checks length", () => { + const checks = [1, 2, 3]; + const result = processChecksForDisplay(normalizeStub, checks, 5); + expect(result).to.deep.equal(checks); + }); + + it("should filter checks based on numToDisplay", () => { + const checks = [1, 2, 3, 4, 5, 6]; + const result = processChecksForDisplay(normalizeStub, checks, 3); + // Should return [1, 3, 5] as n = ceil(6/3) = 2 + expect(result).to.deep.equal([1, 3, 5]); + }); + + it("should handle empty checks array", () => { + const checks = []; + const result = processChecksForDisplay(normalizeStub, checks, 3); + expect(result).to.be.an("array").that.is.empty; + }); + + it("should call normalizeData when normalize is true", () => { + const checks = [1, 2, 3]; + normalizeStub.returns([10, 20, 30]); + + const result = processChecksForDisplay(normalizeStub, checks, null, true); + + expect(normalizeStub.args[0]).to.deep.equal([checks, 1, 100]); + expect(result).to.deep.equal([10, 20, 30]); + }); + + it("should handle both filtering and normalization", () => { + const checks = [1, 2, 3, 4, 5, 6]; + normalizeStub.returns([10, 30, 50]); + + const result = processChecksForDisplay(normalizeStub, checks, 3, true); + + expect(normalizeStub.args[0][0]).to.deep.equal([1, 3, 5]); + expect(result).to.deep.equal([10, 30, 50]); + }); + }); + describe("groupChecksByTime", () => { + const mockChecks = [ + { createdAt: "2024-01-15T10:30:45Z" }, + { createdAt: "2024-01-15T10:45:15Z" }, + { createdAt: "2024-01-15T11:15:00Z" }, + { createdAt: "2024-01-16T10:30:00Z" }, + ]; + + it("should group checks by hour when dateRange is 'day'", () => { + const result = groupChecksByTime(mockChecks, "day"); + + // Get timestamps for 10:00 and 11:00 on Jan 15 + const time1 = new Date("2024-01-15T10:00:00Z").getTime(); + const time2 = new Date("2024-01-15T11:00:00Z").getTime(); + const time3 = new Date("2024-01-16T10:00:00Z").getTime(); + + expect(Object.keys(result)).to.have.lengthOf(3); + + expect(result[time1].checks).to.have.lengthOf(2); + expect(result[time2].checks).to.have.lengthOf(1); + expect(result[time3].checks).to.have.lengthOf(1); + }); + + it("should group checks by day when dateRange is not 'day'", () => { + const result = groupChecksByTime(mockChecks, "week"); + + expect(Object.keys(result)).to.have.lengthOf(2); + expect(result["2024-01-15"].checks).to.have.lengthOf(3); + expect(result["2024-01-16"].checks).to.have.lengthOf(1); + }); + + it("should handle empty checks array", () => { + const result = groupChecksByTime([], "day"); + expect(result).to.deep.equal({}); + }); + + it("should handle single check", () => { + const singleCheck = [{ createdAt: "2024-01-15T10:30:45Z" }]; + const result = groupChecksByTime(singleCheck, "day"); + + const expectedTime = new Date("2024-01-15T10:00:00Z").getTime(); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[expectedTime].checks).to.have.lengthOf(1); + }); + it("should skip invalid dates and process valid ones", () => { + const checksWithInvalidDate = [ + { createdAt: "invalid-date" }, + { createdAt: "2024-01-15T10:30:45Z" }, + { createdAt: null }, + { createdAt: undefined }, + { createdAt: "" }, + ]; + + const result = groupChecksByTime(checksWithInvalidDate, "day"); + + const expectedTime = new Date("2024-01-15T10:00:00Z").getTime(); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[expectedTime].checks).to.have.lengthOf(1); + expect(result[expectedTime].checks[0].createdAt).to.equal("2024-01-15T10:30:45Z"); + }); + it("should handle checks in same time group", () => { + const checksInSameHour = [ + { createdAt: "2024-01-15T10:15:00Z" }, + { createdAt: "2024-01-15T10:45:00Z" }, + ]; + + const result = groupChecksByTime(checksInSameHour, "day"); + + const expectedTime = new Date("2024-01-15T10:00:00Z").getTime(); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[expectedTime].checks).to.have.lengthOf(2); + }); + }); + describe("calculateGroupStats", () => { + // Mock getUptimePercentage function + let uptimePercentageStub; + + beforeEach(() => { + uptimePercentageStub = sinon.stub(); + uptimePercentageStub.returns(95); // Default return value + }); + + it("should calculate stats correctly for a group of checks", () => { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: true, responseTime: 100 }, + { status: false, responseTime: 200 }, + { status: true, responseTime: 300 }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: (2 / 3) * 100, + totalChecks: 3, + totalIncidents: 1, + avgResponseTime: 200, // (100 + 200 + 300) / 3 + }); + }); + + it("should handle empty checks array", () => { + const mockGroup = { + time: "2024-01-15", + checks: [], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: 0, + totalChecks: 0, + totalIncidents: 0, + avgResponseTime: 0, + }); + }); + + it("should handle missing responseTime values", () => { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: true }, + { status: false, responseTime: 200 }, + { status: true, responseTime: undefined }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: (2 / 3) * 100, + totalChecks: 3, + totalIncidents: 1, + avgResponseTime: 200, // 200 / 1 + }); + }); + + it("should handle all checks with status false", () => { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: false, responseTime: 100 }, + { status: false, responseTime: 200 }, + { status: false, responseTime: 300 }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: 0, + totalChecks: 3, + totalIncidents: 3, + avgResponseTime: 200, + }); + }); + + it("should handle all checks with status true", () => { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: true, responseTime: 100 }, + { status: true, responseTime: 200 }, + { status: true, responseTime: 300 }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: 100, + totalChecks: 3, + totalIncidents: 0, + avgResponseTime: 200, + }); + }); + }); + + describe("getMonitorStatsById", () => { + const now = new Date(); + const oneHourAgo = new Date(now - 3600000); + const twoHoursAgo = new Date(now - 7200000); + + const mockMonitor = { + _id: "monitor123", + type: "http", + name: "Test Monitor", + url: "https://test.com", + toObject: () => ({ + _id: "monitor123", + type: "http", + name: "Test Monitor", + url: "https://test.com", + }), + }; + + const mockMonitorPing = { + _id: "monitor123", + type: "ping", + name: "Test Monitor", + url: "https://test.com", + toObject: () => ({ + _id: "monitor123", + type: "http", + name: "Test Monitor", + url: "https://test.com", + }), + }; + + const checkDocs = [ + { + monitorId: "monitor123", + status: true, + responseTime: 100, + createdAt: new Date("2024-01-01T12:00:00Z"), + toObject: function () { + return { + monitorId: this.monitorId, + status: this.status, + responseTime: this.responseTime, + createdAt: this.createdAt, + }; + }, + }, + { + monitorId: "monitor123", + status: true, + responseTime: 150, + createdAt: new Date("2024-01-01T11:00:00Z"), + toObject: function () { + return { + monitorId: this.monitorId, + status: this.status, + responseTime: this.responseTime, + createdAt: this.createdAt, + }; + }, + }, + { + monitorId: "monitor123", + status: false, + responseTime: 200, + createdAt: new Date("2024-01-01T10:00:00Z"), + toObject: function () { + return { + monitorId: this.monitorId, + status: this.status, + responseTime: this.responseTime, + createdAt: this.createdAt, + }; + }, + }, + ]; + const req = { + params: { monitorId: "monitor123" }, + query: { + dateRange: "day", + sortOrder: "desc", + numToDisplay: 10, + normalize: true, + }, + }; + + beforeEach(() => { + checkFindStub.returns({ + sort: () => checkDocs, + }); + monitorFindByIdStub.returns(mockMonitor); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return monitor stats with calculated values, sort order desc", async () => { + req.query.sortOrder = "desc"; + const result = await getMonitorStatsById(req); + expect(result).to.include.keys([ + "_id", + "type", + "name", + "url", + "uptimeDuration", + "lastChecked", + "latestResponseTime", + "periodIncidents", + "periodTotalChecks", + "periodAvgResponseTime", + "periodUptime", + "aggregateData", + ]); + expect(result.latestResponseTime).to.equal(100); + expect(result.periodTotalChecks).to.equal(3); + expect(result.periodIncidents).to.equal(1); + expect(result.periodUptime).to.be.a("number"); + expect(result.aggregateData).to.be.an("array"); + }); + it("should return monitor stats with calculated values, ping type", async () => { + monitorFindByIdStub.returns(mockMonitorPing); + req.query.sortOrder = "desc"; + const result = await getMonitorStatsById(req); + expect(result).to.include.keys([ + "_id", + "type", + "name", + "url", + "uptimeDuration", + "lastChecked", + "latestResponseTime", + "periodIncidents", + "periodTotalChecks", + "periodAvgResponseTime", + "periodUptime", + "aggregateData", + ]); + expect(result.latestResponseTime).to.equal(100); + expect(result.periodTotalChecks).to.equal(3); + expect(result.periodIncidents).to.equal(1); + expect(result.periodUptime).to.be.a("number"); + expect(result.aggregateData).to.be.an("array"); + }); + it("should return monitor stats with calculated values", async () => { + req.query.sortOrder = "asc"; + const result = await getMonitorStatsById(req); + expect(result).to.include.keys([ + "_id", + "type", + "name", + "url", + "uptimeDuration", + "lastChecked", + "latestResponseTime", + "periodIncidents", + "periodTotalChecks", + "periodAvgResponseTime", + "periodUptime", + "aggregateData", + ]); + expect(result.latestResponseTime).to.equal(100); + expect(result.periodTotalChecks).to.equal(3); + expect(result.periodIncidents).to.equal(1); + expect(result.periodUptime).to.be.a("number"); + expect(result.aggregateData).to.be.an("array"); + }); + it("should throw error when monitor is not found", async () => { + monitorFindByIdStub.returns(Promise.resolve(null)); + + const req = { + params: { monitorId: "nonexistent" }, + }; + + try { + await getMonitorStatsById(req); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).to.be.an("Error"); + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorStatsById"); + } + }); + }); + + describe("getMonitorById", () => { + let notificationFindStub; + let monitorSaveStub; + + beforeEach(() => { + // Create stubs + notificationFindStub = sinon.stub(Notification, "find"); + monitorSaveStub = sinon.stub().resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return monitor with notifications when found", async () => { + // Arrange + const monitorId = "123"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + save: monitorSaveStub, + }; + const mockNotifications = [ + { _id: "notif1", message: "Test notification 1" }, + { _id: "notif2", message: "Test notification 2" }, + ]; + + monitorFindByIdStub.resolves(mockMonitor); + notificationFindStub.resolves(mockNotifications); + + const result = await getMonitorById(monitorId); + expect(result._id).to.equal(monitorId); + expect(result.name).to.equal("Test Monitor"); + expect(monitorFindByIdStub.calledWith(monitorId)).to.be.true; + expect(notificationFindStub.calledWith({ monitorId })).to.be.true; + expect(monitorSaveStub.calledOnce).to.be.true; + }); + + it("should throw 404 error when monitor not found", async () => { + // Arrange + const monitorId = "nonexistent"; + monitorFindByIdStub.resolves(null); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.message).to.equal(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)); + expect(error.status).to.equal(404); + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + } + }); + + it("should handle database errors properly", async () => { + // Arrange + const monitorId = "123"; + const dbError = new Error("Database connection failed"); + monitorFindByIdStub.rejects(dbError); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + expect(error.message).to.equal("Database connection failed"); + } + }); + + it("should handle notification fetch errors", async () => { + // Arrange + const monitorId = "123"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + save: monitorSaveStub, + }; + const notificationError = new Error("Notification fetch failed"); + + monitorFindByIdStub.resolves(mockMonitor); + notificationFindStub.rejects(notificationError); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + expect(error.message).to.equal("Notification fetch failed"); + } + }); + + it("should handle monitor save errors", async () => { + // Arrange + const monitorId = "123"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + save: sinon.stub().rejects(new Error("Save failed")), + }; + const mockNotifications = []; + + monitorFindByIdStub.resolves(mockMonitor); + notificationFindStub.resolves(mockNotifications); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + expect(error.message).to.equal("Save failed"); + } + }); + }); + describe("getMonitorsAndSummaryByTeamId", () => { + it("should return monitors and correct summary counts", async () => { + // Arrange + const teamId = "team123"; + const type = "http"; + const mockMonitors = [ + { teamId, type, status: true, isActive: true }, // up + { teamId, type, status: false, isActive: true }, // down + { teamId, type, status: null, isActive: false }, // paused + { teamId, type, status: true, isActive: true }, // up + ]; + monitorFindStub.resolves(mockMonitors); + + // Act + const result = await getMonitorsAndSummaryByTeamId(teamId, type); + + // Assert + expect(result.monitors).to.have.lengthOf(4); + expect(result.monitorCounts).to.deep.equal({ + up: 2, + down: 1, + paused: 1, + total: 4, + }); + expect(monitorFindStub.calledOnceWith({ teamId, type })).to.be.true; + }); + + it("should return empty results for non-existent team", async () => { + // Arrange + monitorFindStub.resolves([]); + + // Act + const result = await getMonitorsAndSummaryByTeamId("nonexistent", "http"); + + // Assert + expect(result.monitors).to.have.lengthOf(0); + expect(result.monitorCounts).to.deep.equal({ + up: 0, + down: 0, + paused: 0, + total: 0, + }); + }); + + it("should handle database errors", async () => { + // Arrange + const error = new Error("Database error"); + error.service = "MonitorModule"; + error.method = "getMonitorsAndSummaryByTeamId"; + monitorFindStub.rejects(error); + + // Act & Assert + try { + await getMonitorsAndSummaryByTeamId("team123", "http"); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).to.equal(error); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("getMonitorsAndSummaryByTeamId"); + } + }); + }); + describe("getMonitorsByTeamId", () => { + beforeEach(() => { + // Chain stubs for Monitor.find().skip().limit().sort() + + // Stub for CHECK_MODEL_LOOKUP model find + checkFindStub.returns({ + sort: sinon.stub().returns({ + limit: sinon.stub().returns([]), + }), + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should return monitors with basic query parameters", async () => { + const mockMonitors = [ + { _id: "1", type: "http", toObject: () => ({ _id: "1", type: "http" }) }, + { _id: "2", type: "ping", toObject: () => ({ _id: "2", type: "ping" }) }, + ]; + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + const req = { + params: { teamId: "team123" }, + query: { + type: "http", + page: 0, + rowsPerPage: 10, + field: "name", + status: false, + checkOrder: "desc", + }, + }; + + monitorCountStub.resolves(2); + + const result = await getMonitorsByTeamId(req); + + expect(result).to.have.property("monitors"); + expect(result).to.have.property("monitorCount", 2); + }); + + it("should return monitors with basic query parameters", async () => { + const mockMonitors = [ + { _id: "1", type: "http", toObject: () => ({ _id: "1", type: "http" }) }, + { _id: "2", type: "ping", toObject: () => ({ _id: "2", type: "ping" }) }, + ]; + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + const req = { + params: { teamId: "team123" }, + query: { + type: "http", + page: 0, + rowsPerPage: 10, + field: "name", + status: true, + checkOrder: "asc", + }, + }; + + monitorCountStub.resolves(2); + + const result = await getMonitorsByTeamId(req); + + expect(result).to.have.property("monitors"); + expect(result).to.have.property("monitorCount", 2); + }); + + it("should handle type filter with array input", async () => { + const req = { + params: { teamId: "team123" }, + query: { + type: ["http", "ping"], + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + await getMonitorsByTeamId(req); + + expect(Monitor.find.firstCall.args[0]).to.deep.equal({ + teamId: "team123", + type: { $in: ["http", "ping"] }, + }); + }); + + it("should handle text search filter", async () => { + const req = { + params: { teamId: "team123" }, + query: { + filter: "search", + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + await getMonitorsByTeamId(req); + + expect(Monitor.find.firstCall.args[0]).to.deep.equal({ + teamId: "team123", + $or: [ + { name: { $regex: "search", $options: "i" } }, + { url: { $regex: "search", $options: "i" } }, + ], + }); + }); + + it("should handle pagination parameters", async () => { + const req = { + params: { teamId: "team123" }, + query: { + page: 2, + rowsPerPage: 5, + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + const result = await getMonitorsByTeamId(req); + expect(result).to.deep.equal({ + monitors: [], + monitorCount: 0, + }); + }); + + it("should handle sorting parameters", async () => { + const req = { + params: { teamId: "team123" }, + query: { + field: "name", + order: "asc", + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + await getMonitorsByTeamId(req); + + const result = await getMonitorsByTeamId(req); + expect(result).to.deep.equal({ + monitors: [], + monitorCount: 0, + }); + }); + + it("should return early when limit is -1", async () => { + // Arrange + const req = { + params: { teamId: "team123" }, + query: { + limit: "-1", + }, + }; + + const mockMonitors = [ + { _id: "1", type: "http" }, + { _id: "2", type: "ping" }, + ]; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + monitorCountStub.resolves(2); + + // Act + const result = await getMonitorsByTeamId(req); + + // Assert + expect(result).to.deep.equal({ + monitors: mockMonitors, + monitorCount: 2, + }); + }); + + it("should normalize checks when normalize parameter is provided", async () => { + const req = { + params: { teamId: "team123" }, + query: { normalize: "true" }, + }; + monitorCountStub.resolves(2); + + const mockMonitors = [ + { _id: "1", type: "http", toObject: () => ({ _id: "1", type: "http" }) }, + { _id: "2", type: "ping", toObject: () => ({ _id: "2", type: "ping" }) }, + ]; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + const result = await getMonitorsByTeamId(req); + expect(result.monitorCount).to.equal(2); + expect(result.monitors).to.have.lengthOf(2); + }); + it("should handle database errors", async () => { + const req = { + params: { teamId: "team123" }, + query: {}, + }; + + const error = new Error("Database error"); + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().throws(error), + }), + }), + }); + + try { + await getMonitorsByTeamId(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("getMonitorsByTeamId"); + expect(err.message).to.equal("Database error"); + } + }); + }); + describe("createMonitor", () => { + it("should create a monitor without notifications", async () => { + let monitorSaveStub = sinon.stub(Monitor.prototype, "save").resolves(); + + const req = { + body: { + name: "Test Monitor", + url: "http://test.com", + type: "http", + notifications: ["someNotification"], + }, + }; + + const expectedMonitor = { + name: "Test Monitor", + url: "http://test.com", + type: "http", + notifications: undefined, + save: monitorSaveStub, + }; + + const result = await createMonitor(req); + expect(result.name).to.equal(expectedMonitor.name); + expect(result.url).to.equal(expectedMonitor.url); + }); + it("should handle database errors", async () => { + const req = { + body: { + name: "Test Monitor", + }, + }; + + try { + await createMonitor(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("createMonitor"); + } + }); + }); + + describe("deleteMonitor", () => { + it("should delete a monitor successfully", async () => { + const monitorId = "123456789"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + url: "http://test.com", + }; + + const req = { + params: { monitorId }, + }; + + monitorFindByIdAndDeleteStub.resolves(mockMonitor); + + const result = await deleteMonitor(req); + + expect(result).to.deep.equal(mockMonitor); + sinon.assert.calledWith(monitorFindByIdAndDeleteStub, monitorId); + }); + + it("should throw error when monitor not found", async () => { + const monitorId = "nonexistent123"; + const req = { + params: { monitorId }, + }; + + monitorFindByIdAndDeleteStub.resolves(null); + + try { + await deleteMonitor(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteMonitor"); + } + }); + + it("should handle database errors", async () => { + const monitorId = "123456789"; + const req = { + params: { monitorId }, + }; + + const dbError = new Error("Database connection error"); + monitorFindByIdAndDeleteStub.rejects(dbError); + + try { + await deleteMonitor(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteMonitor"); + } + }); + }); + + describe("deleteAllMonitors", () => { + it("should delete all monitors for a team successfully", async () => { + const teamId = "team123"; + const mockMonitors = [ + { _id: "1", name: "Monitor 1", teamId }, + { _id: "2", name: "Monitor 2", teamId }, + ]; + + monitorFindStub.resolves(mockMonitors); + monitorDeleteManyStub.resolves({ deletedCount: 2 }); + + const result = await deleteAllMonitors(teamId); + + expect(result).to.deep.equal({ + monitors: mockMonitors, + deletedCount: 2, + }); + sinon.assert.calledWith(monitorFindStub, { teamId }); + sinon.assert.calledWith(monitorDeleteManyStub, { teamId }); + }); + + it("should return empty array when no monitors found", async () => { + const teamId = "emptyTeam"; + + monitorFindStub.resolves([]); + monitorDeleteManyStub.resolves({ deletedCount: 0 }); + + const result = await deleteAllMonitors(teamId); + + expect(result).to.deep.equal({ + monitors: [], + deletedCount: 0, + }); + sinon.assert.calledWith(monitorFindStub, { teamId }); + sinon.assert.calledWith(monitorDeleteManyStub, { teamId }); + }); + + it("should handle database errors", async () => { + const teamId = "team123"; + const dbError = new Error("Database connection error"); + monitorFindStub.rejects(dbError); + + try { + await deleteAllMonitors(teamId); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteAllMonitors"); + } + }); + + it("should handle deleteMany errors", async () => { + const teamId = "team123"; + monitorFindStub.resolves([{ _id: "1", name: "Monitor 1" }]); + monitorDeleteManyStub.rejects(new Error("Delete operation failed")); + + try { + await deleteAllMonitors(teamId); + } catch (err) { + expect(err.message).to.equal("Delete operation failed"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteAllMonitors"); + } + }); + }); + + describe("deleteMonitorsByUserId", () => { + beforeEach(() => {}); + + afterEach(() => { + sinon.restore(); + }); + + it("should delete all monitors for a user successfully", async () => { + // Arrange + const userId = "user123"; + const mockResult = { + deletedCount: 3, + acknowledged: true, + }; + + monitorDeleteManyStub.resolves(mockResult); + + // Act + const result = await deleteMonitorsByUserId(userId); + + // Assert + expect(result).to.deep.equal(mockResult); + sinon.assert.calledWith(monitorDeleteManyStub, { userId: userId }); + }); + + it("should return zero deletedCount when no monitors found", async () => { + // Arrange + const userId = "nonexistentUser"; + const mockResult = { + deletedCount: 0, + acknowledged: true, + }; + + monitorDeleteManyStub.resolves(mockResult); + + // Act + const result = await deleteMonitorsByUserId(userId); + + // Assert + expect(result.deletedCount).to.equal(0); + sinon.assert.calledWith(monitorDeleteManyStub, { userId: userId }); + }); + + it("should handle database errors", async () => { + // Arrange + const userId = "user123"; + const dbError = new Error("Database connection error"); + monitorDeleteManyStub.rejects(dbError); + + // Act & Assert + try { + await deleteMonitorsByUserId(userId); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteMonitorsByUserId"); + } + }); + }); + + describe("editMonitor", () => { + it("should edit a monitor successfully", async () => { + // Arrange + const candidateId = "monitor123"; + const candidateMonitor = { + name: "Updated Monitor", + url: "http://updated.com", + type: "http", + notifications: ["someNotification"], + }; + + const expectedUpdateData = { + name: "Updated Monitor", + url: "http://updated.com", + type: "http", + notifications: undefined, + }; + + const mockUpdatedMonitor = { + _id: candidateId, + ...expectedUpdateData, + }; + + monitorFindByIdAndUpdateStub.resolves(mockUpdatedMonitor); + + // Act + const result = await editMonitor(candidateId, candidateMonitor); + + // Assert + expect(result).to.deep.equal(mockUpdatedMonitor); + sinon.assert.calledWith( + monitorFindByIdAndUpdateStub, + candidateId, + expectedUpdateData, + { + new: true, + } + ); + }); + + it("should return null when monitor not found", async () => { + // Arrange + const candidateId = "nonexistent123"; + const candidateMonitor = { + name: "Updated Monitor", + }; + + monitorFindByIdAndUpdateStub.resolves(null); + + // Act + const result = await editMonitor(candidateId, candidateMonitor); + + // Assert + expect(result).to.be.null; + sinon.assert.calledWith( + monitorFindByIdAndUpdateStub, + candidateId, + { name: "Updated Monitor", notifications: undefined }, + { new: true } + ); + }); + + it("should remove notifications from update data", async () => { + // Arrange + const candidateId = "monitor123"; + const candidateMonitor = { + name: "Updated Monitor", + notifications: ["notification1", "notification2"], + }; + + const expectedUpdateData = { + name: "Updated Monitor", + notifications: undefined, + }; + + monitorFindByIdAndUpdateStub.resolves({ + _id: candidateId, + ...expectedUpdateData, + }); + + // Act + await editMonitor(candidateId, candidateMonitor); + + // Assert + sinon.assert.calledWith( + monitorFindByIdAndUpdateStub, + candidateId, + expectedUpdateData, + { + new: true, + } + ); + }); + + it("should handle database errors", async () => { + // Arrange + const candidateId = "monitor123"; + const candidateMonitor = { + name: "Updated Monitor", + }; + + const dbError = new Error("Database connection error"); + monitorFindByIdAndUpdateStub.rejects(dbError); + + // Act & Assert + try { + await editMonitor(candidateId, candidateMonitor); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("editMonitor"); + } + }); + }); + + describe("addDemoMonitors", () => { + it("should add demo monitors successfully", async () => { + // Arrange + const userId = "user123"; + const teamId = "team123"; + monitorInsertManyStub.resolves([{ _id: "123" }]); + const result = await addDemoMonitors(userId, teamId); + expect(result).to.deep.equal([{ _id: "123" }]); + }); + + it("should handle database errors", async () => { + const userId = "user123"; + const teamId = "team123"; + + const dbError = new Error("Database connection error"); + monitorInsertManyStub.rejects(dbError); + + try { + await addDemoMonitors(userId, teamId); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("addDemoMonitors"); + } + }); + }); +}); diff --git a/Server/tests/services/statusService.test.js b/Server/tests/services/statusService.test.js index 19b0edf19..432f8c20d 100644 --- a/Server/tests/services/statusService.test.js +++ b/Server/tests/services/statusService.test.js @@ -98,6 +98,7 @@ describe("StatusService", () => { expect(check.responseTime).to.equal(100); expect(check.message).to.equal("Test message"); }); + it("should build a check object for pagespeed type", () => { const check = statusService.buildCheck({ monitorId: "test", @@ -193,6 +194,26 @@ describe("StatusService", () => { expect(check.disk).to.equal("disk"); expect(check.host).to.equal("host"); }); + it("should build a check for hardware type with missing data", () => { + const check = statusService.buildCheck({ + monitorId: "test", + type: "hardware", + status: true, + responseTime: 100, + code: 200, + message: "Test message", + payload: {}, + }); + expect(check.monitorId).to.equal("test"); + expect(check.status).to.be.true; + expect(check.statusCode).to.equal(200); + expect(check.responseTime).to.equal(100); + expect(check.message).to.equal("Test message"); + expect(check.cpu).to.deep.equal({}); + expect(check.memory).to.deep.equal({}); + expect(check.disk).to.deep.equal({}); + expect(check.host).to.deep.equal({}); + }); }); describe("insertCheck", () => { it("should log an error if one is thrown", async () => { diff --git a/Server/utils/logger.js b/Server/utils/logger.js index 60d509a6a..e263085a7 100644 --- a/Server/utils/logger.js +++ b/Server/utils/logger.js @@ -7,6 +7,10 @@ class Logger { if (message instanceof Object) { message = JSON.stringify(message, null, 2); } + + if (details instanceof Object) { + details = JSON.stringify(details, null, 2); + } let msg = `${timestamp} ${level}:`; service && (msg += ` [${service}]`); method && (msg += `(${method})`);