import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as ReviewService from '../../src/services/review.service.js'; import { User } from '../../src/models/User.js'; import { ReviewLog } from '../../src/models/ReviewLog.js'; import { StudyItem } from '../../src/models/StudyItem.js'; import { getSRSDate } from '../../src/utils/dateUtils.js'; vi.mock('../../src/models/ReviewLog.js'); vi.mock('../../src/models/StudyItem.js'); describe('Review Service', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2023-01-10T12:00:00Z')); }); afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); }); describe('processReview', () => { const mockUser = (stats = {}) => ({ _id: 'u1', stats: { ...stats }, save: vi.fn() }); const mockItem = (srs = 1) => ({ userId: 'u1', wkSubjectId: 100, srsLevel: srs, stats: { correct: 0, total: 0 }, save: vi.fn(), nextReview: null }); it('should throw error if item not found', async () => { const user = mockUser(); StudyItem.findOne.mockResolvedValue(null); await expect(ReviewService.processReview(user, 999, true)) .rejects.toThrow('Item not found'); expect(StudyItem.findOne).toHaveBeenCalledWith({ userId: user._id, wkSubjectId: 999 }); }); it('should handle standard success flow (Level 1 -> 2)', async () => { const user = mockUser(); delete user.stats; StudyItem.findOne.mockResolvedValue(mockItem()); const ZSRes = await ReviewService.processReview(user, 100, true); expect(user.save).toHaveBeenCalled(); expect(ZSRes.srsLevel).toBe(2); }); it('should handle failure flow (Level 2 -> 1)', async () => { const user = mockUser(); const item = mockItem(2); StudyItem.findOne.mockResolvedValue(item); await ReviewService.processReview(user, 100, false); expect(item.srsLevel).toBe(1); }); it('should increment streak (diff = 1)', async () => { const user = mockUser({ lastStudyDate: '2023-01-09', currentStreak: 5 }); StudyItem.findOne.mockResolvedValue(mockItem()); await ReviewService.processReview(user, 100, true); expect(user.stats.currentStreak).toBe(6); }); it('should maintain streak (diff = 0)', async () => { const user = mockUser({ lastStudyDate: '2023-01-10', currentStreak: 5 }); StudyItem.findOne.mockResolvedValue(mockItem()); await ReviewService.processReview(user, 100, true); expect(user.stats.currentStreak).toBe(5); }); it('should handle future lastStudyDate (diff < 1)', async () => { const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 }); StudyItem.findOne.mockResolvedValue(mockItem()); await ReviewService.processReview(user, 100, true); expect(user.stats.currentStreak).toBe(5); expect(user.stats.lastStudyDate).toBe('2023-01-10'); }); it('should reset streak (diff > 1, no shield available)', async () => { const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2023-01-09' }); StudyItem.findOne.mockResolvedValue(mockItem()); await ReviewService.processReview(user, 100, true); expect(user.stats.currentStreak).toBe(1); }); it('should use shield (diff = 2, shield ready)', async () => { const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2022-01-01' }); StudyItem.findOne.mockResolvedValue(mockItem()); await ReviewService.processReview(user, 100, true); expect(user.stats.currentStreak).toBe(6); expect(user.stats.lastFreezeDate).toBeDefined(); expect(user.stats.lastFreezeDate.toISOString()).toContain('2023-01-10'); }); it('should NOT use shield if gap is too large (diff > 2)', async () => { const user = mockUser({ lastStudyDate: '2023-01-07', currentStreak: 5, lastFreezeDate: '2022-01-01' }); StudyItem.findOne.mockResolvedValue(mockItem()); await ReviewService.processReview(user, 100, true); expect(user.stats.currentStreak).toBe(1); expect(new Date(user.stats.lastFreezeDate).toISOString()).toContain('2022-01-01'); }); it('should use shield (diff = 2) when lastFreezeDate is undefined', async () => { const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5 }); user.stats.lastFreezeDate = null; StudyItem.findOne.mockResolvedValue(mockItem()); await ReviewService.processReview(user, 100, true); expect(user.stats.currentStreak).toBe(6); expect(user.stats.lastFreezeDate).toBeDefined(); }); it('should initialize item stats if missing', async () => { const user = mockUser(); const item = mockItem(); delete item.stats; StudyItem.findOne.mockResolvedValue(item); await ReviewService.processReview(user, 100, true); expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 })); }); it('should handle burning items (level 9 -> 10) and stop reviews', async () => { const user = mockUser(); const item = mockItem(9); StudyItem.findOne.mockResolvedValue(item); const result = await ReviewService.processReview(user, 100, true); expect(item.srsLevel).toBe(10); expect(item.nextReview).toBeNull(); expect(result.srsLevel).toBe(10); }); }); describe('processLesson', () => { const mockUser = () => ({ _id: 'u1' }); const mockItem = () => ({ userId: 'u1', wkSubjectId: 100, srsLevel: 0, save: vi.fn(), nextReview: null }); it('should throw error if item not found', async () => { StudyItem.findOne.mockResolvedValue(null); await expect(ReviewService.processLesson(mockUser(), 999)) .rejects.toThrow('Item not found'); }); it('should initialize lesson with correct SRS level and date (4 hours)', async () => { const user = mockUser(); const item = mockItem(); StudyItem.findOne.mockResolvedValue(item); const result = await ReviewService.processLesson(user, 100); expect(result.success).toBe(true); expect(item.srsLevel).toBe(1); expect(item.save).toHaveBeenCalled(); const now = new Date('2023-01-10T12:00:00Z'); const expected = new Date(now.getTime() + 4 * 3600000); expect(item.nextReview.toISOString()).toBe(expected.toISOString()); }); }); describe('getQueue', () => { it('should sort by priority', async () => { const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) }; StudyItem.find.mockReturnValue(mockChain); await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority'); expect(mockChain.sort).toHaveBeenCalled(); }); it('should shuffle (default behavior)', async () => { const mockChain = { limit: vi.fn().mockResolvedValue([1, 2]) }; StudyItem.find.mockReturnValue(mockChain); await ReviewService.getQueue({ _id: 'u1' }, 10, 'random'); expect(mockChain.limit).toHaveBeenCalled(); }); }); describe('getLessonQueue', () => { it('should return sorted lesson queue', async () => { const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue(['l1', 'l2']) }; StudyItem.find.mockReturnValue(mockChain); const result = await ReviewService.getLessonQueue({ _id: 'u1' }, 5); expect(StudyItem.find).toHaveBeenCalledWith({ userId: 'u1', srsLevel: 0 }); expect(mockChain.sort).toHaveBeenCalledWith({ level: 1, wkSubjectId: 1 }); expect(mockChain.limit).toHaveBeenCalledWith(5); expect(result).toHaveLength(2); }); }); });