diff --git a/client/src/pages/A1InternshipRequestForm.js b/client/src/pages/A1InternshipRequestForm.js index d5e08f69..61e0695a 100644 --- a/client/src/pages/A1InternshipRequestForm.js +++ b/client/src/pages/A1InternshipRequestForm.js @@ -252,10 +252,11 @@ const A1InternshipRequestForm = ({ userRole = "student" }) => { const submitFormData = async () => { try { - const submissionPayload = { - studentName: formData.interneeName, - soonerId: formData.soonerId, - studentEmail: formData.interneeEmail, + // Fetch logged-in student ID from localStorage or context + const studentId = localStorage.getItem("studentId"); // You must store this during login + + const payload = { + student: studentId, workplace: { name: formData.workplaceName, website: formData.website, @@ -271,15 +272,13 @@ const A1InternshipRequestForm = ({ userRole = "student" }) => { endDate: formData.endDate, tasks: formData.tasks, status: "submitted", + approvals: ["advisor"] }; - console.log("Submitting payload:", submissionPayload); - - + const response = await fetch(`${process.env.REACT_APP_API_URL}/api/form/submit`, { method: "POST", headers: { "Content-Type": "application/json" - }, body: JSON.stringify(submissionPayload), }); @@ -293,6 +292,7 @@ const A1InternshipRequestForm = ({ userRole = "student" }) => { throw error; } }; + const sendTaskDescriptions = async (descriptions) => { try { diff --git a/client/src/pages/Home.js b/client/src/pages/Home.js index ffe18418..171453ef 100644 --- a/client/src/pages/Home.js +++ b/client/src/pages/Home.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import "../styles/App.css"; import { FaEnvelope, FaLock, FaEye, FaEyeSlash } from "react-icons/fa"; @@ -13,28 +13,20 @@ function Home() { const [formData, setFormData] = useState({ email: "", password: "", - role: "student", }); const [showPassword, setShowPassword] = useState(false); - const [role] = useState("student"); - - // Sync role into formData.role - useEffect(() => { - setFormData((prev) => ({ ...prev, role })); - }, [role]); const handleInputChange = (e) => { const { name, value } = e.target; - setFormData({ - ...formData, + setFormData((prev) => ({ + ...prev, [name]: value, - }); + })); }; const handleSubmit = async (e) => { e.preventDefault(); - const { email: ouEmail, password, role } = formData; @@ -55,7 +47,7 @@ function Home() { "Content-Type": "application/json", }, body: JSON.stringify({ ouEmail, password, role }), - }, + } ); const data = await response.json(); @@ -64,22 +56,29 @@ function Home() { const user = data.user; if(role === "student"){ // Store only required fields - const limitedUserInfo = { - fullName: user.fullName, - id: user._id, - email:user.ouEmail - }; - - localStorage.setItem("ipmsUser", JSON.stringify(limitedUserInfo)); - navigate("/student-dashboard"); - }else if(role === "supervisor"){ + const limitedUserInfo = { + fullName: user.fullName, + id: user._id, + email:user.ouEmail + }; + + localStorage.setItem("ipmsUser", JSON.stringify(limitedUserInfo)); + navigate("/student-dashboard"); + } else if(role === "supervisor"){ Swal.fire({ icon: "success", title: "Login Successful 🌟", text: `Welcome back, ${role}!`, }); navigate("/supervisor-dashboard"); - }else{ + } else if (role === "coordinator") { + Swal.fire({ + icon: "success", + title: "Login Successful 🌟", + text: `Welcome back, ${role}!`, + }); + navigate("/coordinator-dashboard"); + } else{ Swal.fire({ icon: "success", title: "Login Successful 🌟", @@ -101,6 +100,7 @@ function Home() { Swal.fire({ icon: "error", title: "Login Failed", + text: data.message || "Something went wrong", html: data.message + " " + (data.renewalLink ? `Please click here to request a new token.` @@ -125,9 +125,7 @@ function Home() {
Your ${form_type} form has been ${action}ed by the supervisor.
`; - if (comment) { - emailBody += `Comment: ${comment}
`; - } + const studentEmail = form.student_id?.email || form.interneeEmail || form.studentEmail || null; + let emailSubject = `Form ${action === "approve" ? "Approved" : "Rejected"}`; + let emailBody = `Your ${form_type} form has been ${action}ed by the supervisor.
`; + if (comment) { + emailBody += `Comment: ${comment}
`; } const student_id = form.student_id || form.internee_id || form.student; const student = await UserTokenRequest.findById(student_id); const student_mail = student?.ouEmail || form?.interneeEmail; - try { - await EmailService.sendEmail({ - to: student_mail, - subject: emailSubject, - html: emailBody, - }); + try { + await EmailService.sendEmail({ + to: student_mail, + subject: emailSubject, + html: emailBody, + }); } catch (err) { - console.error("Email sending error:", err); + console.error("Email sending error:", err); } console.log("Email sent to:", student_mail); - - res.status(200).json({ - message: `Form ${action}ed successfully`, - updatedForm: form, - }); + res.status(200).json({ message: `Form ${action}ed successfully`, updatedForm: form }); } catch (err) { console.error("SupervisorFormAction error:", err); res.status(500).json({ message: "Error processing form", error: err.message }); } }; -// =========================================== // -// Coordinator Dashboard // -// =========================================== // - -exports.getCoordinatorRequests = async (req, res) => { - try { +const getCoordinatorRequests = async (req, res) => { + try { const requests = await InternshipRequest.find({ coordinator_status: "pending", }).populate("student", "userName email"); + res.status(200).json(requests); } catch (err) { res.status(500).json({ message: "Failed to fetch requests" }); } }; -// Coordinator View Single Request -exports.getCoordinatorRequestDetails = async (req, res) => { +const getCoordinatorRequestDetails = async (req, res) => { try { - const requestData = await InternshipRequest.findById(req.params.id).lean(); + const requestData = await InternshipRequest.findById(req.params.id) + .populate("student", "userName email") + .lean(); + if (!requestData) { return res.status(404).json({ message: "Request not found" }); } - res.status(200).json({ requestData, supervisorStatus: "Not Submitted" }); + const supervisorStatus = requestData.supervisor_status || "Not Submitted"; + + res.status(200).json({ requestData, supervisorStatus }); } catch (err) { res.status(500).json({ message: "Failed to fetch details" }); } }; -// Coordinator Approve Request -exports.coordinatorApproveRequest = async (req, res) => { +const coordinatorApproveRequest = async (req, res) => { try { const request = await InternshipRequest.findByIdAndUpdate( req.params.id, - { coordinator_status: "approved" }, + { status: "approved" }, { new: true } - ); + ).populate("student", "userName email"); if (!request) { return res.status(404).json({ message: "Request not found" }); } + request.coordinator_status = "Approved"; + request.coordinator_comment = "Approved by Coordinator"; + await request.save(); + await EmailService.sendEmail({ to: request.student.email, subject: "Internship Request Approved", @@ -191,22 +174,25 @@ exports.coordinatorApproveRequest = async (req, res) => { } }; -// Coordinator Reject Request -exports.coordinatorRejectRequest = async (req, res) => { +const coordinatorRejectRequest = async (req, res) => { const { reason } = req.body; if (!reason) return res.status(400).json({ message: "Reason required" }); try { const request = await InternshipRequest.findByIdAndUpdate( req.params.id, - { coordinator_status: "rejected" }, + { status: "rejected" }, { new: true } - ); + ).populate("student", "userName email"); if (!request) { return res.status(404).json({ message: "Request not found" }); } + request.coordinator_status = "Rejected"; + request.coordinator_comment = reason; + await request.save(); + await EmailService.sendEmail({ to: request.student.email, subject: "Internship Request Rejected", @@ -218,3 +204,145 @@ exports.coordinatorRejectRequest = async (req, res) => { res.status(500).json({ message: "Rejection failed", error: err.message }); } }; + +const coordinatorResendRequest = async (req, res) => { + try { + const submission = await InternshipRequest.findById(req.params.id); + if (!submission) + return res.status(404).json({ message: "Submission not found" }); + + submission.coordinator_reminder_count = 0; + submission.last_coordinator_reminder_at = new Date(); + submission.coordinator_status = "pending"; + await submission.save(); + + return res.status(200).json({ message: "Reminder cycle restarted." }); + } catch (error) { + console.error("Error in coordinatorResendRequest:", error); + return res.status(500).json({ message: "Server error while resending request." }); + } +}; + +const deleteStudentSubmission = async (req, res) => { + try { + const { id } = req.params; + const studentId = req.user._id; + + const submission = await InternshipRequest.findById(id); + if (!submission) + return res.status(404).json({ message: "Submission not found." }); + + if (submission.student.toString() !== studentId.toString()) { + return res.status(403).json({ message: "You are not authorized to delete this submission." }); + } + + if (submission.coordinator_status !== "pending") { + return res.status(400).json({ message: "Submission already reviewed. Cannot delete." }); + } + + await InternshipRequest.findByIdAndDelete(id); + return res.status(200).json({ message: "Submission successfully deleted by student." }); + } catch (err) { + console.error("Error deleting student submission:", err); + return res.status(500).json({ message: "Internal server error." }); + } +}; + +const getStudentSubmissions = async (req, res) => { + try { + const studentId = req.user._id; + const submissions = await InternshipRequest.find({ student: studentId }).sort({ createdAt: -1 }); + res.status(200).json(submissions); + } catch (error) { + console.error("Error fetching student submissions:", error); + res.status(500).json({ message: "Failed to fetch submissions." }); + } +}; + +const getPendingSubmissions = async (req, res) => { + try { + const pendingRequests = await InternshipRequest.find({ + supervisor_status: "pending", + }).populate("student", "fullName ouEmail"); + + res.status(200).json(pendingRequests); + } catch (err) { + res.status(500).json({ + message: "Failed to fetch pending supervisor submissions", + error: err.message, + }); + } +}; + +const approveSubmission = async (req, res) => { + const { id } = req.params; + const { comment } = req.body; + try { + const request = await InternshipRequest.findByIdAndUpdate( + id, + { supervisor_status: "approved", supervisor_comment: comment || "" }, + { new: true } + ); + if (!request) return res.status(404).json({ message: "Submission not found" }); + + res.json({ message: "Submission approved", updated: request }); + } catch (err) { + res.status(500).json({ message: "Approval failed", error: err.message }); + } +}; + +const rejectSubmission = async (req, res) => { + const { id } = req.params; + const { comment } = req.body; + try { + const request = await InternshipRequest.findByIdAndUpdate( + id, + { supervisor_status: "rejected", supervisor_comment: comment || "" }, + { new: true } + ); + if (!request) return res.status(404).json({ message: "Submission not found" }); + + res.json({ message: "Submission rejected", updated: request }); + } catch (err) { + res.status(500).json({ message: "Rejection failed", error: err.message }); + } +}; + +const deleteStalledSubmission = async (req, res) => { + try { + const { id } = req.params; + + const submission = await InternshipRequest.findById(id); + if (!submission) { + return res.status(404).json({ message: "Submission not found." }); + } + + if (submission.coordinator_status !== "pending") { + return res.status(400).json({ message: "Submission already reviewed. Cannot delete." }); + } + + await InternshipRequest.findByIdAndDelete(id); + + return res.status(200).json({ message: "Submission deleted successfully." }); + } catch (error) { + console.error("Error deleting submission:", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + + +module.exports = { + getCoordinatorRequests, + getCoordinatorRequestDetails, + coordinatorApproveRequest, + coordinatorRejectRequest, + coordinatorResendRequest, + deleteStudentSubmission, + getStudentSubmissions, + getPendingSubmissions, + getSupervisorForms, + handleSupervisorFormAction, + approveSubmission, + rejectSubmission, + deleteStalledSubmission, +}; diff --git a/server/index.js b/server/index.js index 80b7fe97..fbc3f98c 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ const express = require("express"); const mongoose = require("mongoose"); const cors = require("cors"); const User = require("./models/User"); + const formRoutes = require("./routes/formRoutes"); const emailRoutes = require("./routes/emailRoutes"); @@ -12,8 +13,11 @@ const tokenRoutes = require("./routes/token"); const approvalRoutes = require("./routes/approvalRoutes"); const studentRoutes = require("./routes/studentRoutes"); + const outcomeRoutes = require("./routes/outcomeRoutes"); + + // Import cron job manager and register jobs const cronJobManager = require("./utils/cronUtils").cronJobManager; const { registerAllJobs } = require("./jobs/registerCronJobs"); @@ -22,6 +26,9 @@ const fourWeekReportRoutes = require("./routes/fourWeekReportRoutes"); const path = require("path"); +const cronJobRoutes = require("./routes/cronJobRoutes"); + + const app = express(); app.use(express.json()); app.use(cors()); @@ -139,13 +146,11 @@ app.post("/api/evaluation", async (req, res) => { } }); - - - //Form A.4 const presentationRoutes = require("./routes/presentationRoutes"); app.use("/api/presentation", presentationRoutes); +// Graceful shutdown (async Mongoose support) process.on("SIGINT", async () => { try { cronJobManager.stopAllJobs(); @@ -158,5 +163,6 @@ process.on("SIGINT", async () => { } }); + const PORT = process.env.PORT || 5001; app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); \ No newline at end of file diff --git a/server/jobs/reminderEmail.js b/server/jobs/reminderEmail.js index c0435d25..a8b1a6f2 100644 --- a/server/jobs/reminderEmail.js +++ b/server/jobs/reminderEmail.js @@ -1,65 +1,76 @@ const emailService = require("../services/emailService"); -const Submission = require("../models/InternshipRequest"); +// const Submission = require("../models/InternshipRequest"); // ❌ Remove this const NotificationLog = require("../models/NotifLog"); const User = require("../models/User"); +const UserTokenRequest = require("../models/TokenRequest"); +const logger = require("../utils/logger"); const WeeklyReport = require("../models/WeeklyReport"); const SupervisorReview = require("../models/SupervisorReview"); const InternshipRequest = require("../models/InternshipRequest"); -const UserTokenRequest = require("../models/TokenRequest"); -const logger = require("../utils/logger"); const dayjs = require("dayjs"); -// Coordinator reminder: weekly report reviewed by supervisor but not yet commented by coordinator +// ================= Coordinator Reminder ================= const coordinatorReminder = async () => { const now = dayjs(); + const fiveWorkingDays = now.subtract(7, "day").toDate(); + try { - const supervisorReviews = await SupervisorReview.find({}); - - for (const review of supervisorReviews) { - const { studentId, weeks } = review; - const reports = await WeeklyReport.find({ - studentId, - week: { $in: weeks }, - }); - - const allCoordinatorCommentsMissing = reports.every( - (r) => !r.coordinatorComments || r.coordinatorComments.trim() === "" - ); - - if (!allCoordinatorCommentsMissing) continue; - - const coordinatorEmail = reports[0]?.coordinatorEmail; - const studentEmail = reports[0]?.email; - - const internship = await InternshipRequest.findOne({ - email: studentEmail, - }); - if (!internship || dayjs().isAfter(dayjs(internship.endDate))) continue; - - await emailService.sendEmail({ - to: coordinatorEmail, - subject: `Reminder: Coordinator Review Pending (Weeks ${weeks.join( - ", " - )})`, - html: `Supervisor has reviewed weeks ${weeks.join( - ", " - )}.
-Please add your coordinator comments in IPMS dashboard before the internship ends.
`, - text: `Reminder to review weeks ${weeks.join(", ")} as coordinator.`, - }); - - logger.info( - `[Reminder Sent] Coordinator: "${coordinatorEmail}" for weeks: ${weeks.join( - ", " - )}` - ); + const pendingSubs = await InternshipRequest.find({ + coordinator_status: "pending", + supervisor_status: "approved", + createdAt: { $lt: fiveWorkingDays }, + }); + + for (const submission of pendingSubs) { + const student = await User.findById(submission.student_id); + const coordinator = await User.findById(submission.coordinator_id); + + const reminderCount = submission.coordinator_reminder_count || 0; + const lastReminded = submission.last_coordinator_reminder_at || submission.createdAt; + const nextReminderDue = dayjs(lastReminded).add(5, "day"); + const shouldRemindAgain = now.isAfter(nextReminderDue); + + if (reminderCount >= 2 && shouldRemindAgain && !submission.studentNotified) { + await emailService.sendEmail({ + to: student.email, + subject: `Coordinator Not Responding for "${submission.name}"`, + html: `Your submission "${submission.name}" has not been approved by the coordinator even after 2 reminders.
+You can now choose to resend or delete the request.
`, + text: `Your submission "${submission.name}" is still awaiting coordinator approval.`, + }); + + await NotificationLog.create({ + submissionId: submission._id, + type: "studentEscalation", + recipientEmail: student.email, + message: `Student notified about stalled coordinator approval for "${submission.name}"`, + }); + + submission.studentNotified = true; + await submission.save(); + + logger.info(`🔔 Escalation: student notified for "${submission.name}"`); + } else if (shouldRemindAgain) { + await emailService.sendEmail({ + to: coordinator.email, + subject: `Reminder: Please Approve Submission "${submission.name}"`, + html: `This is a reminder to review and approve the internship submission by ${submission.student_name}.
`, + text: `Reminder to approve submission "${submission.name}".`, + }); + + submission.coordinator_reminder_count = reminderCount + 1; + submission.last_coordinator_reminder_at = new Date(); + await submission.save(); + + logger.info(`📧 Reminder sent to coordinator for "${submission.name}"`); + } } } catch (err) { - logger.error("[CoordinatorReminder Error]:", err.message || err); + logger.error("❌ Error in coordinatorReminder:", err.message); } }; -// Utility to get all forms of type A1, A2, A3 +// ================= Supervisor Reminder ================= const getAllForms = async (filter = {}) => { const models = { A1: require("../models/InternshipRequest"), @@ -67,26 +78,22 @@ const getAllForms = async (filter = {}) => { A3: require("../models/Evaluation"), }; - const formPromises = Object.entries(models).map( - async ([form_type, Model]) => { - const results = await Model.find(filter); - return results; - } - ); + const formPromises = Object.entries(models).map(async ([form_type, Model]) => { + return await Model.find(filter); + }); const allResults = await Promise.all(formPromises); return allResults.flat(); }; -// Supervisor reminder: weekly progress reports pending review const supervisorReminder = async () => { const now = dayjs(); - const fiveWorkingDaysAgo = now.subtract(7, "day").toDate(); + const fiveWorkingDays = now.subtract(7, "day").toDate(); try { - const pendingSubs = await Submission.find({ + const pendingSubs = await getAllForms({ supervisor_status: "pending", - createdAt: { $lt: fiveWorkingDaysAgo }, + last_supervisor_reminder_at: { $lt: fiveWorkingDays }, }); const supervisors = await UserTokenRequest.find({ @@ -95,40 +102,35 @@ const supervisorReminder = async () => { }); for (const submission of pendingSubs) { - const student = await User.findById(submission.student_id); - const supervisor = await User.findById(submission.supervisor_id); - - if (!student || !supervisor) continue; - + const student = await UserTokenRequest.findById(submission.student_id); const reminderCount = submission.supervisor_reminder_count || 0; - const lastReminded = - submission.last_supervisor_reminder_at || submission.createdAt; + const lastReminded = submission.last_supervisor_reminder_at || submission.createdAt; const nextReminderDue = dayjs(lastReminded).add(5, "day"); const shouldRemindAgain = now.isAfter(nextReminderDue); if (reminderCount >= 2 && shouldRemindAgain) { await emailService.sendEmail({ - to: student.email, - subject: `Supervisor Not Responding for "${submission.name}"`, - html: `Your submission "${submission.name}" has not been reviewed by your supervisor after multiple reminders.
-Please consider resending or deleting the request.
`, - text: `Your submission "${submission.name}" is still awaiting supervisor review.`, + to: student.ouEmail, + subject: `Supervisor Not Responding for "${submission._id}"`, + html: `Your submission "${submission._id}" has not been reviewed by the supervisor after multiple reminders.
+Please consider resending the form or deleting the request.
`, + text: `Your submission "${submission._id}" is still awaiting supervisor review.`, }); await NotificationLog.create({ - submissionId: submission._id, + submission_id: submission._id, type: "studentEscalation", - recipientEmail: student.email, - message: `Student notified about supervisor inaction for "${submission.name}".`, + recipient_email: student.ouEmail, + message: `Student notified about supervisor status on: "${submission._id}"`, }); - logger.info(`[Escalated] Student notified for: "${submission.name}"`); + logger.info(`Returned to student for resubmit/delete: "${submission._id}"`); } else if (shouldRemindAgain) { - for (const sup of supervisors) { + for (const supervisor of supervisors) { await emailService.sendEmail({ - to: sup.ouEmail, + to: supervisor.ouEmail, subject: `Reminder: Please Review Submission "${submission._id}"`, - html: `This is a reminder to review the submission by ${student.email}.
`, + html: `This is a reminder to review the submission by ${student.ouEmail}.
`, text: `Reminder to review submission "${submission._id}".`, }); } @@ -142,13 +144,11 @@ const supervisorReminder = async () => { logger.error(`Failed to save submission: ${err.message}`); } - logger.info( - `[Reminder Sent] Supervisor: "${supervisor.email}" for "${submission.name}"` - ); + logger.info(`Reminder sent to supervisor for "${submission._id}"`); } } } catch (err) { - logger.error("[SupervisorReminder Error]:", err.message || err); + logger.error("Error in supervisorReminder:", err.message); } }; diff --git a/server/middleware/authMiddleware.js b/server/middleware/authMiddleware.js index 6ea8cb9c..736b2f6e 100644 --- a/server/middleware/authMiddleware.js +++ b/server/middleware/authMiddleware.js @@ -1,9 +1,8 @@ const User = require("../models/User"); const UserTokenRequest = require("../models/TokenRequest"); -exports.isSupervisor = (req, res, next) => { - // const supervisor = Sup.find({$id: username}) - +// 🔹 Supervisor Middleware +const isSupervisor = (req, res, next) => { req.user = { role: "supervisor" }; // Mocking user role for demo if (req.user.role === "supervisor") { next(); @@ -12,42 +11,30 @@ exports.isSupervisor = (req, res, next) => { } }; -/* - // This is token management if we'll use it in the future -exports.isSupervisor = async (req, res, next) => { - try { - // Token management - const raw = req.headers.authorization?.split(" ")[1]; // "BearerHello,
+The student ${request.student.userName} has resent their internship approval request due to inactivity.
+Please review and take necessary action.
+ ` + }); + + res.json({ message: "Request resent successfully" }); + } catch (err) { + console.error("Resend error:", err); + res.status(500).json({ message: "Failed to resend request" }); + } +}); +router.delete("/requests/:id", async (req, res) => { + try { + const deleted = await InternshipRequest.findByIdAndDelete(req.params.id); + if (!deleted) return res.status(404).json({ message: "Request not found" }); + res.json({ message: "Request deleted successfully" }); + } catch (err) { + console.error("Delete error:", err); + res.status(500).json({ message: "Failed to delete request" }); + } +}); +router.post("/student", async (req, res) => { + const { ouEmail } = req.body; + if (!ouEmail) return res.status(400).json({ message: "Missing email" }); + + try { + const request = await InternshipRequest.findOne({ "student.email": ouEmail }); + if (!request) return res.json({ approvalStatus: "not_submitted" }); + + return res.json({ approvalStatus: request.status || "draft" }); + } catch (err) { + console.error("Student route error:", err); + res.status(500).json({ message: "Server error" }); + } +}); + module.exports = router; diff --git a/server/utils/cronUtils.test.js b/server/utils/cronUtils.test.js index 346f41ad..53c12db1 100644 --- a/server/utils/cronUtils.test.js +++ b/server/utils/cronUtils.test.js @@ -1,3 +1,4 @@ +// cronUtils.test.js const cron = require("node-cron"); const logger = require("./logger"); const cronJobManager = require("./cronUtils"); @@ -14,86 +15,135 @@ jest.mock("./logger", () => ({ })); describe("cronUtils", () => { - const mockJobFunction = jest.fn().mockResolvedValue(); + let mockJobFunction; beforeEach(() => { + mockJobFunction = jest.fn().mockResolvedValue(); cron.validate.mockClear(); cron.schedule.mockClear(); logger.info.mockClear(); logger.warn.mockClear(); logger.error.mockClear(); - cronJobManager.stopAllJobs(); + cronJobManager.jobs.clear(); }); afterEach(() => { - cronJobManager.stopAllJobs(); jest.clearAllMocks(); }); - test("should create an instance of CronJobManager", () => { + it("create instance of CronJobManager", () => { expect(cronJobManager).toBeDefined(); - expect(cronJobManager.jobs instanceof Map).toBe(true); + expect(cronJobManager.jobs).toEqual(new Map()); + expect(cronJobManager.logger).toEqual(logger); }); - test("should register job with runOnInit = true", () => { - cron.validate.mockReturnValue(true); - cron.schedule.mockReturnValue({ stop: jest.fn() }); - - const result = cronJobManager.registerJob( - "Job1", - "*/1 * * * *", - mockJobFunction, - { runOnInit: true } - ); - - expect(result).toBe(true); - expect(logger.info).toHaveBeenCalledWith( - `Running job Job1 immediately on init` - ); + describe("registerJob", () => { + + beforeEach(() => { + cron.validate.mockClear(); + cron.schedule.mockClear(); + logger.info.mockClear(); + logger.warn.mockClear(); + logger.error.mockClear(); + }) + + it("registerJob succeeds with runOnInit", () => { + cron.validate.mockReturnValue(true); + cron.schedule.mockReturnValue({ stop: jest.fn() }); + const result = cronJobManager.registerJob( + "testJob", + "*/5 * * * *", + mockJobFunction, + { runOnInit: true } + ); + // Check scedule and logger.info + expect(result).toBe(true); + expect(cron.schedule).toHaveBeenCalledTimes(1); + expect(cron.schedule).toHaveBeenCalledWith( + "*/5 * * * *", + expect.any(Function), + expect.objectContaining({ scheduled: true }) + ); + expect(logger.info).toHaveBeenCalledWith( + `Running job testJob immediately on init` + ); + }); + + it("registerJob succeeds without runOnInit", () => { + cron.validate.mockReturnValue(true); + cron.schedule.mockReturnValue({ stop: jest.fn() }); + const result = cronJobManager.registerJob( + "testJob", + "*/5 * * * *", + mockJobFunction, + { timezone: "UTC" } + ); + expect(result).toBe(true); + expect(cron.schedule).toHaveBeenCalledTimes(1); + expect(cron.schedule).toHaveBeenCalledWith( + "*/5 * * * *", + expect.any(Function), + expect.objectContaining({ scheduled: true, timezone: "UTC" }) + ); + expect(logger.info).not.toHaveBeenCalledWith( + `Running job testJob immediately on init` + ); + }); + + it("registerJob errors with invalid cron", () => { + cron.validate.mockReturnValue(false); + const result = cronJobManager.registerJob( + "invalidJob", + "invalid-cron-expression", + mockJobFunction + ); + // Check the correct logs were sent + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + "Invalid cron expression: invalid-cron-expression" + ); + expect(logger.info).toHaveBeenCalledTimes(0); + }); + + it("registerJob warns & replaces duplicate jobs", () => { + cron.validate.mockReturnValue(true); + cron.schedule.mockReturnValue({ stop: jest.fn() }); + // Create Job to check against + cronJobManager.registerJob("testJob", "*/5 * * * *", mockJobFunction); + // attempt to register with same job name + const result = cronJobManager.registerJob( + "testJob", + "*/10 * * * *", + mockJobFunction + ); + // Check correct logs were sent + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + "Job 'testJob' already exists. Replacing it..." + ); + expect(logger.info).toHaveBeenCalledWith("Stopped job: testJob"); + }); }); - test("should register job with runOnInit = false", () => { + it("stopJob stops a given job", () => { cron.validate.mockReturnValue(true); cron.schedule.mockReturnValue({ stop: jest.fn() }); - - const result = cronJobManager.registerJob( - "Job2", - "*/1 * * * *", - mockJobFunction - ); - - expect(result).toBe(true); + // Create Job to stop + cronJobManager.registerJob("testJob", "*/5 * * * *", mockJobFunction); + // Check that it exists + expect(cronJobManager.jobs.has("testJob")).toBe(true); + // remove it + cronJobManager.stopJob("testJob"); + // check that its gone + expect(logger.info).toHaveBeenCalledWith("Stopped job: testJob"); }); - test("should not register job with invalid cron", () => { - cron.validate.mockReturnValue(false); - - const result = cronJobManager.registerJob("InvalidJob", "invalid", mockJobFunction); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - "Invalid cron expression: invalid" - ); - }); - - test("should stop a specific job", () => { + it("listJobs prints out jobs", () => { cron.validate.mockReturnValue(true); - const stopFn = jest.fn(); - cron.schedule.mockReturnValue({ stop: stopFn }); - - cronJobManager.registerJob("Job3", "*/1 * * * *", mockJobFunction); - cronJobManager.stopJob("Job3"); - - expect(stopFn).toHaveBeenCalled(); + // Create Job to list + cronJobManager.registerJob("testJob", "*/5 * * * *", mockJobFunction); + const result = cronJobManager.listJobs(); + expect(result[0].name).toBe("testJob"); }); - test("should list registered jobs", () => { - cron.validate.mockReturnValue(true); - cron.schedule.mockReturnValue({ stop: jest.fn() }); - - cronJobManager.registerJob("Job4", "*/1 * * * *", mockJobFunction); - - const jobs = cronJobManager.listJobs(); - expect(jobs.length).toBeGreaterThan(0); - }); });