From e027cf96b483d52f850f8eae5f37d0ab9365a15c Mon Sep 17 00:00:00 2001 From: Jayshree Rathore Date: Thu, 13 Aug 2026 01:43:55 +0530 Subject: [PATCH 1/2] feat: implement secure forgot password flow with hashed tokens (#1426) --- server/controller/Auth/forgotPassword.js | 3 +- server/controller/Auth/resetPassword.js | 9 +- server/middleware/rateLimiter.js | 2 +- server/tests/passwordReset.test.js | 126 +++++++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 server/tests/passwordReset.test.js diff --git a/server/controller/Auth/forgotPassword.js b/server/controller/Auth/forgotPassword.js index 7d660f27..c42dc2b8 100644 --- a/server/controller/Auth/forgotPassword.js +++ b/server/controller/Auth/forgotPassword.js @@ -26,9 +26,10 @@ const forgotPasswordLogic = async (req, res) => { // Generate 32-byte secure token const token = crypto.randomBytes(32).toString("hex"); + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); const expiry = Date.now() + 15 * 60 * 1000; // 15 min validity - user.resetToken = token; + user.resetToken = hashedToken; user.resetTokenExpiry = expiry; await user.save(); diff --git a/server/controller/Auth/resetPassword.js b/server/controller/Auth/resetPassword.js index 79357268..c52c13ea 100644 --- a/server/controller/Auth/resetPassword.js +++ b/server/controller/Auth/resetPassword.js @@ -1,6 +1,6 @@ -// controller/Auth/resetPassword.js const bcrypt = require("bcryptjs"); const UserModel = require("../../models/user.models"); +const crypto = require("crypto"); const { validatePassword } = require("../../utils/passwordValidator"); const resetPassword = async (req, res) => { @@ -24,8 +24,11 @@ const resetPassword = async (req, res) => { }); } - // Find user by token - const user = await UserModel.findOne({ resetToken: token }); + // Hash incoming token to match the database stored token + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); + + // Find user by hashed token + const user = await UserModel.findOne({ resetToken: hashedToken }); if (!user) { return res.status(400).json({ diff --git a/server/middleware/rateLimiter.js b/server/middleware/rateLimiter.js index 96d96e1a..d441c488 100644 --- a/server/middleware/rateLimiter.js +++ b/server/middleware/rateLimiter.js @@ -19,7 +19,7 @@ const compilerLimiter = rateLimit({ keyGenerator: (req) => req.user?.id || req.ip, }); -router.post('/execute', authenticateToken, compilerLimiter, async (req, res) => { ... }); + // 1 minute window, max 5 feedback submissions per IP const feedbackLimiter = rateLimit({ diff --git a/server/tests/passwordReset.test.js b/server/tests/passwordReset.test.js new file mode 100644 index 00000000..5dfe6612 --- /dev/null +++ b/server/tests/passwordReset.test.js @@ -0,0 +1,126 @@ +const request = require('supertest'); +const app = require('../index').backend; +const UserModel = require('../models/user.models'); +const nodemailer = require('nodemailer'); + +// Mock nodemailer +jest.mock('nodemailer', () => { + const sendMailMock = jest.fn().mockResolvedValue(true); + return { + createTransport: jest.fn().mockReturnValue({ + verify: jest.fn().mockResolvedValue(true), + sendMail: sendMailMock, + }), + _sendMailMock: sendMailMock, + }; +}); + +// Mock the User Model to avoid real DB connections +jest.mock('../models/user.models', () => { + let mockDB = {}; // Stores user state + + return { + findOne: jest.fn(async (query) => { + // Find by email or resetToken + if (query.$or) { + const email = query.$or[0].email || query.$or[1].Email; + return mockDB[email] || null; + } + if (query.resetToken) { + return Object.values(mockDB).find(u => u.resetToken === query.resetToken) || null; + } + return null; + }), + __setMockUser: (user) => { + mockDB[user.email] = { + ...user, + save: jest.fn().mockImplementation(async function() { + // Simulate saving by updating the mockDB with this object's state + mockDB[this.email] = this; + return this; + }) + }; + }, + __clearMock: () => { mockDB = {}; } + }; +}); + + +describe('Secure Password Reset Flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + UserModel.__clearMock(); + + // Set dummy env vars to prevent 500 errors + process.env.EMAIL_USER = 'dummy@test.com'; + process.env.EMAIL_PASS = 'dummypass'; + + // Seed our mock user + UserModel.__setMockUser({ + username: 'reset_tester', + email: 'reset@test.com', + Email: 'reset@test.com', + password: 'OldPassword123!', + resetToken: undefined, + resetTokenExpiry: undefined + }); + }); + + test('POST /api/auth/forgot-password should generate hashed token and send email', async () => { + const res = await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'reset@test.com' }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + + const sendMailMock = require('nodemailer')._sendMailMock; + expect(sendMailMock).toHaveBeenCalledTimes(1); + + // Verify token was hashed in the mock DB + const dbUser = await UserModel.findOne({ $or: [{email: 'reset@test.com'}, {Email: 'reset@test.com'}] }); + expect(dbUser.resetToken).toBeDefined(); + expect(dbUser.resetToken).toMatch(/^[a-f0-9]{64}$/); // SHA-256 length + expect(dbUser.resetTokenExpiry).toBeDefined(); + }); + + test('POST /api/auth/reset-password should successfully reset password with valid token', async () => { + await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'reset@test.com' }); + + const sendMailMock = require('nodemailer')._sendMailMock; + const mailOptions = sendMailMock.mock.calls[0][0]; + + // Extract raw token from email + const tokenMatch = mailOptions.html.match(/token=([a-f0-9]+)/); + const token = tokenMatch[1]; + + const resetRes = await request(app) + .post('/api/auth/reset-password') + .send({ + token, + newPassword: 'NewSecurePassword123!', + }); + + expect(resetRes.statusCode).toBe(200); + expect(resetRes.body.success).toBe(true); + + // Verify token is invalidated + const dbUser = await UserModel.findOne({ $or: [{email: 'reset@test.com'}, {Email: 'reset@test.com'}] }); + expect(dbUser.resetToken).toBeUndefined(); + expect(dbUser.resetTokenExpiry).toBeUndefined(); + }); + + test('POST /api/auth/reset-password should reject invalid token', async () => { + const resetRes = await request(app) + .post('/api/auth/reset-password') + .send({ + token: 'invalid_or_made_up_token', + newPassword: 'NewSecurePassword123!', + }); + + expect(resetRes.statusCode).toBe(400); + expect(resetRes.body.success).toBe(false); + }); +}); From 75fcdb78b0a7abf26fe285cee5b6fbbe11d2257f Mon Sep 17 00:00:00 2001 From: Jayshree Rathore Date: Thu, 13 Aug 2026 02:00:33 +0530 Subject: [PATCH 2/2] refactor: wrap course progress updates in MongoDB transactions for atomicity (#1427) --- server/controller/Lesson/lessoncontroller.js | 153 +-------- server/services/progressService.js | 223 +++++++++++++ server/tests/progressTransaction.test.js | 311 +++++++++++++++++++ 3 files changed, 548 insertions(+), 139 deletions(-) create mode 100644 server/services/progressService.js create mode 100644 server/tests/progressTransaction.test.js diff --git a/server/controller/Lesson/lessoncontroller.js b/server/controller/Lesson/lessoncontroller.js index 4afb7135..c4f9e294 100644 --- a/server/controller/Lesson/lessoncontroller.js +++ b/server/controller/Lesson/lessoncontroller.js @@ -1,8 +1,5 @@ const Lesson = require('../../models/lesson'); -const Progress = require('../../models/progress'); -const User = require('../../models/user.models'); -const Analytics = require('../../models/analytics'); -const Notification = require('../../models/notification'); +const { recordLessonCompletion } = require('../../services/progressService'); const LESSON_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/i; const MAX_LESSON_ID_LENGTH = 80; @@ -24,10 +21,7 @@ const STATIC_LESSON_LIMITS = { }; const STATIC_LESSON_ID_RE = /^(html|css|js|c|dbms|dsa|express|mongo|node|oop|react)-lesson-?(\d+)$/i; -const getSubjectFromLessonId = (lessonId) => { - if (!lessonId || typeof lessonId !== 'string') return 'Other'; - return lessonId.split('-')[0].replace(/\d+$/, '') || lessonId; -}; + const parseFiniteNumber = (value) => { if (typeof value === 'number') return Number.isFinite(value) ? value : null; @@ -196,140 +190,21 @@ exports.completeLesson = async (req, res) => { const { score, coins, learningTime, type } = payload.value; - const existingProgress = await Progress.findOne({ email }); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - // Single source of truth for streak: date-diff logic based on lastActiveDate. - // This value is used BOTH for the XP multiplier below AND for the DB write, - // so the reward and the persisted/displayed streak can never diverge. - let currentStreak = existingProgress?.currentStreak || 0; - let longestStreak = existingProgress?.longestStreak || 0; - - if (!existingProgress?.lastActiveDate) { - currentStreak = 1; - } else { - const lastDate = new Date(existingProgress.lastActiveDate); - lastDate.setHours(0, 0, 0, 0); - const diffDays = Math.floor( - (today - lastDate) / (1000 * 60 * 60 * 24) - ); - if (diffDays === 1) { - currentStreak += 1; - } else if (diffDays > 1) { - currentStreak = 1; - } - // diffDays === 0 -> same day, don't change streak - } - - longestStreak = Math.max(longestStreak, currentStreak); - - let progress = await Progress.findOne({ email }); - const isNewCompletion = !progress || !progress.completedLessons.includes(lessonId); - - let earnedXp = 0; - let earnedBadges = progress?.badges || []; - - if (isNewCompletion) { - const baseXp = Math.round(score * 0.5); - - const events = await Analytics.find({ email }).sort({ createdAt: 1 }).lean(); - - // Multiplier now reuses the single `currentStreak` computed above - // instead of a second, separately-derived (and previously shadowed) - // analytics-based streak. This keeps the XP reward and the persisted - // streak consistent with each other. - let multiplier = 1.0; - if (currentStreak >= 7) multiplier = 1.5; - else if (currentStreak >= 3) multiplier = 1.2; - - earnedXp = Math.round(baseXp * multiplier); - - const { checkAndAwardBadges } = require('../../config/badges'); - const progressData = progress ? { ...progress.toObject(), badges: earnedBadges } : { completedLessons: [], scores: {}, badges: [], currentStreak: 0 }; - const result = checkAndAwardBadges( - progressData, - { score, analyticsEvents: events } - ); - earnedBadges = result.earnedBadgeIds; - } - - const currentXp = progress?.xp || 0; - const newTotalXp = currentXp + earnedXp; - const newLevel = Math.floor(newTotalXp / 100) + 1; - - progress = await Progress.findOneAndUpdate( - { email }, - { - $addToSet: { completedLessons: lessonId }, - $set: { - [`scores.${lessonId}`]: score, - xp: newTotalXp, - level: newLevel, - badges: earnedBadges, - currentStreak, - longestStreak, - lastActiveDate: today, - } - }, - { - new: true, - upsert: true, - } - ); - - const user = await User.findOne({ email }).lean(); - - try { - await Analytics.create({ - userId: user?._id || null, - email, - username: user?.username || progress.username || '', - lessonId, - subject: getSubjectFromLessonId(lessonId), - score, - completed: true, - points: score, - coins, - learningTime, - type, - }); - } catch (analyticsErr) { - console.error('Analytics event creation failed:', analyticsErr); - } - - try { - await Notification.create({ - email, - type: 'lesson_complete', - message: `You completed the lesson "${lessonId}" with a score of ${score}!`, - relatedEntity: lessonId, - }); - } catch (notifErr) { - console.error('Notification creation failed:', notifErr); - } - - if (currentStreak > 1 && currentStreak % 5 === 0) { - try { - await Notification.create({ - email, - type: 'streak_milestone', - message: `You've reached a ${currentStreak}-day learning streak! Keep it up!`, - relatedEntity: '', - }); - } catch (notifErr) { - console.error('Streak notification creation failed:', notifErr); - } - } + // Delegate all DB writes to the transactional service. All related + // documents (Progress, Analytics, Notification) commit atomically or + // are rolled back together via a Mongoose session transaction. + const result = await recordLessonCompletion({ + email, + lessonId, + score, + coins, + learningTime, + type, + }); res.json({ message: 'Lesson marked as completed', - completedLessons: progress.completedLessons, - scores: progress.scores, - currentStreak: progress.currentStreak, - longestStreak: progress.longestStreak, - dailyGoal: progress.dailyGoal, + ...result, }); } catch (err) { console.error(err); diff --git a/server/services/progressService.js b/server/services/progressService.js new file mode 100644 index 00000000..044f24ad --- /dev/null +++ b/server/services/progressService.js @@ -0,0 +1,223 @@ +/** + * progressService.js + * + * Centralises all course-progress related database writes and wraps them in a + * single MongoDB / Mongoose session transaction so that every related document + * (Progress, Analytics, Notification) either commits together or is rolled back + * together, eliminating the risk of partial writes. + * + * Required: MongoDB must be running as a replica set (even a single-node one) + * for multi-document transactions to be supported. + */ + +'use strict'; + +const mongoose = require('mongoose'); +const Progress = require('../models/progress'); +const Analytics = require('../models/analytics'); +const Notification = require('../models/notification'); +const User = require('../models/user.models'); +const { checkAndAwardBadges } = require('../config/badges'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const getSubjectFromLessonId = (lessonId) => { + if (!lessonId || typeof lessonId !== 'string') return 'Other'; + return lessonId.split('-')[0].replace(/\d+$/, '') || lessonId; +}; + +/** + * Derive the updated streak values from the stored progress document. + * Returns { currentStreak, longestStreak }. + */ +const computeStreak = (existingProgress, today) => { + let currentStreak = existingProgress?.currentStreak || 0; + let longestStreak = existingProgress?.longestStreak || 0; + + if (!existingProgress?.lastActiveDate) { + currentStreak = 1; + } else { + const lastDate = new Date(existingProgress.lastActiveDate); + lastDate.setHours(0, 0, 0, 0); + const diffDays = Math.floor((today - lastDate) / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) { + currentStreak += 1; + } else if (diffDays > 1) { + currentStreak = 1; + } + // diffDays === 0 → same day, streak unchanged + } + + longestStreak = Math.max(longestStreak, currentStreak); + return { currentStreak, longestStreak }; +}; + +// --------------------------------------------------------------------------- +// Public service method +// --------------------------------------------------------------------------- + +/** + * recordLessonCompletion + * + * Atomically: + * 1. Reads current Progress (within session) + * 2. Computes XP, streak, level, badges + * 3. Upserts Progress document + * 4. Creates an Analytics event + * 5. Creates Notification(s) + * + * If any step throws, the session aborts and all writes are rolled back. + * + * @param {Object} params + * @param {string} params.email - User email (from verified JWT) + * @param {string} params.lessonId - Validated lesson ID + * @param {number} params.score - 0–100 + * @param {number} params.coins - 0–1000 + * @param {number} params.learningTime - seconds, 0–21600 + * @param {string} params.type - lesson | quiz | practice | project + * + * @returns {Promise} The updated Progress document fields + */ +const recordLessonCompletion = async ({ + email, + lessonId, + score, + coins, + learningTime, + type, +}) => { + const session = await mongoose.startSession(); + + try { + let updatedProgress; + + await session.withTransaction(async () => { + // ── 1. Read existing progress inside the transaction ────────────────── + const existingProgress = await Progress.findOne({ email }).session(session); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // ── 2. Compute streak ───────────────────────────────────────────────── + const { currentStreak, longestStreak } = computeStreak(existingProgress, today); + + // ── 3. Compute XP and badges (only on first completion) ─────────────── + const isNewCompletion = + !existingProgress || !existingProgress.completedLessons.includes(lessonId); + + let earnedXp = 0; + let earnedBadges = existingProgress?.badges || []; + + if (isNewCompletion) { + const baseXp = Math.round(score * 0.5); + + let multiplier = 1.0; + if (currentStreak >= 7) multiplier = 1.5; + else if (currentStreak >= 3) multiplier = 1.2; + + earnedXp = Math.round(baseXp * multiplier); + + // Fetch analytics events for badge computation (read-only, outside + // the transactional write set so we don't enlarge lock scope) + const events = await Analytics.find({ email }) + .sort({ createdAt: 1 }) + .lean() + .session(session); + + const progressData = existingProgress + ? { ...(typeof existingProgress.toObject === 'function' ? existingProgress.toObject() : existingProgress), badges: earnedBadges } + : { completedLessons: [], scores: {}, badges: [], currentStreak: 0 }; + + const result = checkAndAwardBadges(progressData, { + score, + analyticsEvents: events, + }); + earnedBadges = result.earnedBadgeIds; + } + + const currentXp = existingProgress?.xp || 0; + const newTotalXp = currentXp + earnedXp; + const newLevel = Math.floor(newTotalXp / 100) + 1; + + // ── 4. Upsert Progress (transactional) ──────────────────────────────── + updatedProgress = await Progress.findOneAndUpdate( + { email }, + { + $addToSet: { completedLessons: lessonId }, + $set: { + [`scores.${lessonId}`]: score, + xp: newTotalXp, + level: newLevel, + badges: earnedBadges, + currentStreak, + longestStreak, + lastActiveDate: today, + }, + }, + { new: true, upsert: true, session } + ); + + // ── 5. Fetch user for analytics (non-blocking if missing) ───────────── + const user = await User.findOne({ email }).lean().session(session); + + // ── 6. Create Analytics event (transactional) ───────────────────────── + await Analytics.create( + [ + { + userId: user?._id || null, + email, + username: user?.username || '', + lessonId, + subject: getSubjectFromLessonId(lessonId), + score, + completed: true, + points: score, + coins, + learningTime, + type, + }, + ], + { session } + ); + + // ── 7. Create Notification(s) (transactional) ───────────────────────── + const notificationsToCreate = [ + { + email, + type: 'lesson_complete', + message: `You completed the lesson "${lessonId}" with a score of ${score}!`, + relatedEntity: lessonId, + }, + ]; + + if (currentStreak > 1 && currentStreak % 5 === 0) { + notificationsToCreate.push({ + email, + type: 'streak_milestone', + message: `You've reached a ${currentStreak}-day learning streak! Keep it up!`, + relatedEntity: '', + }); + } + + await Notification.create(notificationsToCreate, { session }); + }); + + return { + completedLessons: updatedProgress.completedLessons, + scores: updatedProgress.scores, + currentStreak: updatedProgress.currentStreak, + longestStreak: updatedProgress.longestStreak, + dailyGoal: updatedProgress.dailyGoal, + xp: updatedProgress.xp, + level: updatedProgress.level, + }; + } finally { + // Always release the session, whether or not the transaction succeeded. + session.endSession(); + } +}; + +module.exports = { recordLessonCompletion }; diff --git a/server/tests/progressTransaction.test.js b/server/tests/progressTransaction.test.js new file mode 100644 index 00000000..62fb9672 --- /dev/null +++ b/server/tests/progressTransaction.test.js @@ -0,0 +1,311 @@ +/** + * progressTransaction.test.js + * + * Unit / integration tests for the transactional progressService. + * + * All Mongoose models and mongoose itself are mocked so the suite runs + * offline (no MongoDB required). The tests verify: + * 1. Happy path – Progress, Analytics, Notification all created atomically. + * 2. Rollback – When a DB write throws, the session is aborted and + * endSession is always called. + * 3. Streak milestone notification – fired when streak % 5 === 0. + * 4. Idempotency – Repeated completion of the same lesson yields no extra XP. + */ + +'use strict'; + +// --------------------------------------------------------------------------- +// Declare stores at module scope so they are accessible inside jest.mock +// factories (jest.mock is hoisted, but the factory closure captures module- +// scope variables, not outer-block variables defined after jest.mock calls). +// --------------------------------------------------------------------------- + +// These will be mutated per-test via Object.assign / push +const stores = { + progress: {}, + analytics: [], + notifications: [], +}; + +// Controls whether withTransaction simulates a failure +const flags = { + transactionShouldFail: false, +}; + +// --------------------------------------------------------------------------- +// Mock mongoose +// --------------------------------------------------------------------------- + +jest.mock('mongoose', () => { + return { + startSession: jest.fn().mockImplementation(() => + Promise.resolve({ + endSession: jest.fn(), + withTransaction: jest.fn().mockImplementation(async (fn) => { + if (flags.transactionShouldFail) { + throw new Error('Transaction aborted: simulated write conflict'); + } + await fn(); + }), + }) + ), + }; +}); + +// --------------------------------------------------------------------------- +// Mock models +// --------------------------------------------------------------------------- + +jest.mock('../models/progress', () => { + // Returns a chainable query-like object + const makeQuery = (resolvedValue) => ({ + session: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + then: (resolve, reject) => Promise.resolve(resolvedValue).then(resolve, reject), + catch: (reject) => Promise.resolve(resolvedValue).catch(reject), + }); + + return { + findOne: jest.fn().mockImplementation(({ email }) => { + return makeQuery(stores.progress[email] || null); + }), + findOneAndUpdate: jest.fn().mockImplementation(async ({ email }, update) => { + const existing = stores.progress[email] || { + completedLessons: [], + scores: {}, + badges: [], + xp: 0, + level: 1, + currentStreak: 0, + longestStreak: 0, + lastActiveDate: null, + dailyGoal: 1, + }; + if (update.$addToSet?.completedLessons) { + const id = update.$addToSet.completedLessons; + if (!existing.completedLessons.includes(id)) { + existing.completedLessons.push(id); + } + } + if (update.$set) Object.assign(existing, update.$set); + stores.progress[email] = existing; + return existing; + }), + }; +}); + +jest.mock('../models/analytics', () => { + const makeQuery = (resolvedValue) => ({ + session: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + then: (resolve, reject) => Promise.resolve(resolvedValue).then(resolve, reject), + catch: (reject) => Promise.resolve(resolvedValue).catch(reject), + }); + + return { + find: jest.fn().mockImplementation(() => makeQuery([])), + create: jest.fn().mockImplementation(async (docs) => { + const arr = Array.isArray(docs) ? docs : [docs]; + arr.forEach((d) => stores.analytics.push(typeof d === 'object' ? d : {})); + return arr; + }), + }; +}); + +jest.mock('../models/notification', () => ({ + create: jest.fn().mockImplementation(async (docs) => { + const arr = Array.isArray(docs) ? docs : [docs]; + arr.forEach((d) => stores.notifications.push(typeof d === 'object' ? d : {})); + return arr; + }), +})); + +jest.mock('../models/user.models', () => { + const makeQuery = (resolvedValue) => ({ + session: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + then: (resolve, reject) => Promise.resolve(resolvedValue).then(resolve, reject), + catch: (reject) => Promise.resolve(resolvedValue).catch(reject), + }); + return { + findOne: jest.fn().mockImplementation(() => + makeQuery({ _id: 'user123', username: 'tester' }) + ), + }; +}); + +jest.mock('../config/badges', () => ({ + checkAndAwardBadges: jest.fn().mockImplementation((progressData) => ({ + earnedBadgeIds: progressData.badges || [], + })), +})); + +// --------------------------------------------------------------------------- +// Import SUT after all mocks are in place +// --------------------------------------------------------------------------- + +const { recordLessonCompletion } = require('../services/progressService'); +const mongoose = require('mongoose'); + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('progressService – recordLessonCompletion (transactional)', () => { + const baseParams = { + email: 'student@test.com', + lessonId: 'html-lesson-1', + score: 80, + coins: 10, + learningTime: 300, + type: 'lesson', + }; + + let capturedSession; + + beforeEach(async () => { + // Reset stores + stores.progress = {}; + stores.analytics.length = 0; + stores.notifications.length = 0; + flags.transactionShouldFail = false; + + // Rebuild fresh session mock each test + capturedSession = { + endSession: jest.fn(), + withTransaction: jest.fn().mockImplementation(async (fn) => { + if (flags.transactionShouldFail) { + throw new Error('Transaction aborted: simulated write conflict'); + } + await fn(); + }), + }; + mongoose.startSession.mockResolvedValue(capturedSession); + + jest.clearAllMocks(); + // Re-apply the resolved value since clearAllMocks resets it + mongoose.startSession.mockResolvedValue(capturedSession); + }); + + // ── 1. Happy path ────────────────────────────────────────────────────────── + test('should commit Progress, Analytics and Notification atomically', async () => { + const result = await recordLessonCompletion(baseParams); + + // Session lifecycle + expect(mongoose.startSession).toHaveBeenCalledTimes(1); + expect(capturedSession.withTransaction).toHaveBeenCalledTimes(1); + expect(capturedSession.endSession).toHaveBeenCalledTimes(1); + + // Progress updated + expect(stores.progress['student@test.com']).toBeDefined(); + expect(stores.progress['student@test.com'].completedLessons).toContain('html-lesson-1'); + expect(stores.progress['student@test.com'].xp).toBeGreaterThan(0); + + // Analytics created + expect(stores.analytics).toHaveLength(1); + expect(stores.analytics[0]).toMatchObject({ + email: 'student@test.com', + lessonId: 'html-lesson-1', + score: 80, + }); + + // Lesson-complete notification created + const notifTypes = stores.notifications.map((n) => n.type); + expect(notifTypes).toContain('lesson_complete'); + + // Return value has correct shape + expect(result).toMatchObject({ + completedLessons: expect.arrayContaining(['html-lesson-1']), + currentStreak: expect.any(Number), + longestStreak: expect.any(Number), + }); + }); + + // ── 2. Rollback on DB failure ────────────────────────────────────────────── + test('should abort transaction and always call endSession on failure', async () => { + flags.transactionShouldFail = true; + + await expect(recordLessonCompletion(baseParams)).rejects.toThrow( + 'Transaction aborted' + ); + + // endSession must always be called, even after a failure + expect(capturedSession.endSession).toHaveBeenCalledTimes(1); + + // Nothing should have been persisted + expect(stores.progress).toEqual({}); + expect(stores.analytics).toHaveLength(0); + expect(stores.notifications).toHaveLength(0); + }); + + // ── 3. Streak milestone notification ────────────────────────────────────── + test('should create a streak_milestone notification when streak hits a multiple of 5', async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + // Seed: user on a 4-day streak, last active yesterday + stores.progress['student@test.com'] = { + completedLessons: [], + scores: {}, + badges: [], + xp: 0, + level: 1, + currentStreak: 4, + longestStreak: 4, + lastActiveDate: yesterday, + dailyGoal: 1, + }; + + const ProgressMock = require('../models/progress'); + ProgressMock.findOne.mockImplementation(({ email }) => { + const val = stores.progress[email] || null; + return { + session: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + then: (resolve, reject) => Promise.resolve(val).then(resolve, reject), + catch: (reject) => Promise.resolve(val).catch(reject), + }; + }); + + await recordLessonCompletion(baseParams); + + const streakNotif = stores.notifications.find((n) => n.type === 'streak_milestone'); + expect(streakNotif).toBeDefined(); + expect(streakNotif.message).toMatch(/5-day/); + }); + + // ── 4. Idempotency – repeated lesson completion ──────────────────────────── + test('should not award extra XP when the same lesson is completed again', async () => { + // Pre-seed: lesson already completed with 40 XP + stores.progress['student@test.com'] = { + completedLessons: ['html-lesson-1'], + scores: { 'html-lesson-1': 80 }, + badges: [], + xp: 40, + level: 1, + currentStreak: 1, + longestStreak: 1, + lastActiveDate: new Date(), + dailyGoal: 1, + }; + + const ProgressMock = require('../models/progress'); + ProgressMock.findOne.mockImplementation(({ email }) => { + const val = stores.progress[email] || null; + return { + session: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + then: (resolve, reject) => Promise.resolve(val).then(resolve, reject), + catch: (reject) => Promise.resolve(val).catch(reject), + }; + }); + + const result = await recordLessonCompletion(baseParams); + + // XP unchanged + expect(stores.progress['student@test.com'].xp).toBe(40); + expect(result.xp).toBe(40); + }); +});