diff --git a/src/analytics/analytics.controller.breakdown.spec.ts b/src/analytics/analytics.controller.breakdown.spec.ts new file mode 100644 index 0000000..c21930f --- /dev/null +++ b/src/analytics/analytics.controller.breakdown.spec.ts @@ -0,0 +1,284 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './providers/analytics.service'; +import { AnalyticsExportService } from './providers/analytics-export.service'; +import { AnalyticsBreakdownService } from './providers/analytics-breakdown.service'; +import { GetAnalyticsQueryDto, TimeFilter } from './dto/get-analytics-query.dto'; +import { AnalyticsBreakdownResponse } from './dto/analytics-breakdown-response.dto'; +import { EventTypeBreakdown } from './dto/analytics-breakdown-response.dto'; + +describe('AnalyticsController - Breakdown Endpoints', () => { + let controller: AnalyticsController; + let analyticsService: jest.Mocked; + let analyticsExportService: jest.Mocked; + let analyticsBreakdownService: jest.Mocked; + + const mockBreakdownResponse: AnalyticsBreakdownResponse = { + breakdown: [ + { + eventType: 'question_view', + count: 124, + displayName: 'Question Viewed', + percentage: 58.8, + }, + { + eventType: 'answer_submit', + count: 87, + displayName: 'Answer Submitted', + percentage: 41.2, + }, + ], + totalEvents: 211, + uniqueEventTypes: 2, + dateRange: '2024-01-01 to 2024-01-31', + }; + + const mockTopEventTypes: EventTypeBreakdown[] = [ + { + eventType: 'question_view', + count: 124, + displayName: 'Question Viewed', + percentage: 58.8, + }, + { + eventType: 'answer_submit', + count: 87, + displayName: 'Answer Submitted', + percentage: 41.2, + }, + ]; + + const mockEventTypes = ['question_view', 'answer_submit', 'puzzle_solved']; + + beforeEach(async () => { + const mockAnalyticsService = { + getAnalytics: jest.fn(), + findAll: jest.fn(), + }; + + const mockAnalyticsExportService = { + exportAnalytics: jest.fn(), + generateFilename: jest.fn(), + }; + + const mockAnalyticsBreakdownService = { + getBreakdown: jest.fn(), + getTopEventTypes: jest.fn(), + getAvailableEventTypes: jest.fn(), + getBreakdownForEventTypes: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [ + { + provide: AnalyticsService, + useValue: mockAnalyticsService, + }, + { + provide: AnalyticsExportService, + useValue: mockAnalyticsExportService, + }, + { + provide: AnalyticsBreakdownService, + useValue: mockAnalyticsBreakdownService, + }, + ], + }).compile(); + + controller = module.get(AnalyticsController); + analyticsService = module.get(AnalyticsService); + analyticsExportService = module.get(AnalyticsExportService); + analyticsBreakdownService = module.get(AnalyticsBreakdownService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getBreakdown', () => { + it('should return analytics breakdown', async () => { + const query: GetAnalyticsQueryDto = { + timeFilter: TimeFilter.WEEKLY, + userId: '123e4567-e89b-12d3-a456-426614174000', + }; + + analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); + + const result = await controller.getBreakdown(query); + + expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); + expect(result).toEqual(mockBreakdownResponse); + }); + + it('should handle empty query parameters', async () => { + const query: GetAnalyticsQueryDto = {}; + + analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); + + const result = await controller.getBreakdown(query); + + expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); + expect(result).toEqual(mockBreakdownResponse); + }); + + it('should handle service errors', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + const error = new Error('Service error'); + + analyticsBreakdownService.getBreakdown.mockRejectedValue(error); + + await expect(controller.getBreakdown(query)).rejects.toThrow('Service error'); + }); + }); + + describe('getTopEventTypes', () => { + it('should return top event types with default limit', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + + analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); + + const result = await controller.getTopEventTypes(undefined, query); + + expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(10, query); + expect(result).toEqual(mockTopEventTypes); + }); + + it('should return top event types with custom limit', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + const limit = 5; + + analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); + + const result = await controller.getTopEventTypes(limit, query); + + expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(5, query); + expect(result).toEqual(mockTopEventTypes); + }); + + it('should clamp limit to minimum value', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + const limit = 0; + + analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); + + const result = await controller.getTopEventTypes(limit, query); + + expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(1, query); + expect(result).toEqual(mockTopEventTypes); + }); + + it('should clamp limit to maximum value', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + const limit = 100; + + analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); + + const result = await controller.getTopEventTypes(limit, query); + + expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(50, query); + expect(result).toEqual(mockTopEventTypes); + }); + + it('should handle service errors', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + const error = new Error('Service error'); + + analyticsBreakdownService.getTopEventTypes.mockRejectedValue(error); + + await expect(controller.getTopEventTypes(10, query)).rejects.toThrow('Service error'); + }); + }); + + describe('getAvailableEventTypes', () => { + it('should return available event types', async () => { + analyticsBreakdownService.getAvailableEventTypes.mockResolvedValue(mockEventTypes); + + const result = await controller.getAvailableEventTypes(); + + expect(analyticsBreakdownService.getAvailableEventTypes).toHaveBeenCalled(); + expect(result).toEqual(mockEventTypes); + }); + + it('should handle empty event types', async () => { + analyticsBreakdownService.getAvailableEventTypes.mockResolvedValue([]); + + const result = await controller.getAvailableEventTypes(); + + expect(result).toEqual([]); + }); + + it('should handle service errors', async () => { + const error = new Error('Service error'); + + analyticsBreakdownService.getAvailableEventTypes.mockRejectedValue(error); + + await expect(controller.getAvailableEventTypes()).rejects.toThrow('Service error'); + }); + }); + + describe('query parameter validation', () => { + it('should handle all query parameters correctly', async () => { + const query: GetAnalyticsQueryDto = { + timeFilter: TimeFilter.MONTHLY, + from: '2024-01-01T00:00:00Z', + to: '2024-01-31T23:59:59Z', + userId: '123e4567-e89b-12d3-a456-426614174000', + sessionId: '456e7890-e89b-12d3-a456-426614174000', + }; + + analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); + + const result = await controller.getBreakdown(query); + + expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); + expect(result).toEqual(mockBreakdownResponse); + }); + + it('should handle partial query parameters', async () => { + const query: GetAnalyticsQueryDto = { + timeFilter: TimeFilter.WEEKLY, + userId: '123e4567-e89b-12d3-a456-426614174000', + }; + + analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); + + const result = await controller.getBreakdown(query); + + expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); + expect(result).toEqual(mockBreakdownResponse); + }); + }); + + describe('response structure validation', () => { + it('should return properly structured breakdown response', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + + analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); + + const result = await controller.getBreakdown(query); + + expect(result).toHaveProperty('breakdown'); + expect(result).toHaveProperty('totalEvents'); + expect(result).toHaveProperty('uniqueEventTypes'); + expect(result).toHaveProperty('dateRange'); + expect(Array.isArray(result.breakdown)).toBe(true); + }); + + it('should return properly structured top event types', async () => { + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; + + analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); + + const result = await controller.getTopEventTypes(10, query); + + expect(Array.isArray(result)).toBe(true); + result.forEach(item => { + expect(item).toHaveProperty('eventType'); + expect(item).toHaveProperty('count'); + expect(item).toHaveProperty('displayName'); + expect(item).toHaveProperty('percentage'); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/gamification/controllers/streak.controller.spec.ts b/src/gamification/controllers/streak.controller.spec.ts index dafe643..bc155e0 100644 --- a/src/gamification/controllers/streak.controller.spec.ts +++ b/src/gamification/controllers/streak.controller.spec.ts @@ -1,7 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StreakController } from './streak.controller'; import { DailyStreakService } from '../providers/daily-streak.service'; -import { StreakResponseDto, StreakLeaderboardResponseDto } from '../dto/streak.dto'; +import { + StreakResponseDto, + StreakLeaderboardResponseDto, +} from '../dto/streak.dto'; describe('StreakController', () => { let controller: StreakController; @@ -64,14 +67,14 @@ describe('StreakController', () => { const mockLeaderboardResponse: StreakLeaderboardResponseDto = { entries: [ { - userId: 1, + userId: '1', username: 'user1', streakCount: 10, longestStreak: 15, lastActiveDate: new Date(), }, { - userId: 2, + userId: '2', username: 'user2', streakCount: 8, longestStreak: 12, @@ -83,11 +86,15 @@ describe('StreakController', () => { limit: 10, }; - mockStreakService.getStreakLeaderboard.mockResolvedValue(mockLeaderboardResponse); + mockStreakService.getStreakLeaderboard.mockResolvedValue( + mockLeaderboardResponse, + ); const result = await controller.getStreakLeaderboard(query); - expect(mockStreakService.getStreakLeaderboard).toHaveBeenCalledWith(query); + expect(mockStreakService.getStreakLeaderboard).toHaveBeenCalledWith( + query, + ); expect(result).toEqual(mockLeaderboardResponse); }); @@ -99,7 +106,9 @@ describe('StreakController', () => { limit: 10, }; - mockStreakService.getStreakLeaderboard.mockResolvedValue(mockLeaderboardResponse); + mockStreakService.getStreakLeaderboard.mockResolvedValue( + mockLeaderboardResponse, + ); const result = await controller.getStreakLeaderboard({}); @@ -125,4 +134,4 @@ describe('StreakController', () => { expect(result).toEqual(mockStats); }); }); -}); \ No newline at end of file +}); diff --git a/src/gamification/gamification.module.ts b/src/gamification/gamification.module.ts index 7c8709b..ba6c5ec 100644 --- a/src/gamification/gamification.module.ts +++ b/src/gamification/gamification.module.ts @@ -8,6 +8,12 @@ import { DailyStreakService } from './providers/daily-streak.service'; import { StreakListener } from './listeners/streak.listener'; import { DailyStreak } from './entities/daily-streak.entity'; import { User } from '../users/user.entity'; +import { UpdateStreakService } from './providers/update-streak.service'; +import { GetStreakService } from './providers/get-streak.service'; +import { GetStreakLeaderboardService } from './providers/get-streak-leaderboard.service'; +import { CheckAndAwardMilestonesService } from './providers/check-and-award-milestones.service'; +import { BuildStreakResponseService } from './providers/build-streak-response.service'; +import { GetStreakStatsService } from './providers/get-streak-stats.service'; @Module({ imports: [ @@ -15,7 +21,17 @@ import { User } from '../users/user.entity'; TypeOrmModule.forFeature([DailyStreak, User]), ], controllers: [GamificationController, StreakController], - providers: [GamificationService, DailyStreakService, StreakListener], + providers: [ + GamificationService, + DailyStreakService, + StreakListener, + UpdateStreakService, + GetStreakService, + GetStreakLeaderboardService, + CheckAndAwardMilestonesService, + BuildStreakResponseService, + GetStreakStatsService, + ], exports: [GamificationService, DailyStreakService], }) export class GamificationModule {} diff --git a/src/gamification/providers/build-streak-response.service.ts b/src/gamification/providers/build-streak-response.service.ts new file mode 100644 index 0000000..0ba98af --- /dev/null +++ b/src/gamification/providers/build-streak-response.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { STREAK_MILESTONES } from '../constants/streak.constants'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { StreakResponseDto } from '../dto/streak.dto'; + +@Injectable() +export class BuildStreakResponseService { + buildStreakResponse(streak: DailyStreak): StreakResponseDto { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const lastActive = new Date(streak.lastActiveDate); + lastActive.setHours(0, 0, 0, 0); + const hasSolvedToday = today.getTime() === lastActive.getTime(); + const milestones = Object.keys(STREAK_MILESTONES).map(Number).sort((a, b) => a - b); + const nextMilestone = milestones.find(m => m > streak.streakCount); + const daysUntilNextMilestone = nextMilestone ? nextMilestone - streak.streakCount : null; + return { + streakCount: streak.streakCount, + longestStreak: streak.longestStreak, + lastActiveDate: streak.lastActiveDate, + hasSolvedToday, + nextMilestone, + daysUntilNextMilestone: daysUntilNextMilestone || undefined, + }; + } +} \ No newline at end of file diff --git a/src/gamification/providers/check-and-award-milestones.service.ts b/src/gamification/providers/check-and-award-milestones.service.ts new file mode 100644 index 0000000..5a79f47 --- /dev/null +++ b/src/gamification/providers/check-and-award-milestones.service.ts @@ -0,0 +1,43 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { STREAK_MILESTONES, STREAK_EVENTS } from '../constants/streak.constants'; +import { BonusRewardDto } from '../dto/bonus-reward.dto'; + +@Injectable() +export class CheckAndAwardMilestonesService { + private readonly logger = new Logger(CheckAndAwardMilestonesService.name); + + constructor( + @InjectRepository(DailyStreak) + private readonly streakRepository: Repository, + private readonly eventEmitter: EventEmitter2, + ) {} + + async checkAndAwardMilestones(streak: DailyStreak): Promise { + const milestones = Object.keys(STREAK_MILESTONES).map(Number).sort((a, b) => a - b); + for (const milestone of milestones) { + if (streak.streakCount >= milestone && + (!streak.lastMilestoneReached || streak.lastMilestoneReached < milestone)) { + const milestoneConfig = STREAK_MILESTONES[milestone as keyof typeof STREAK_MILESTONES]; + const bonusReward: BonusRewardDto = { + userId: streak.userId, + bonusXp: milestoneConfig.xp, + bonusTokens: milestoneConfig.tokens, + reason: milestoneConfig.description, + }; + this.eventEmitter.emit(STREAK_EVENTS.MILESTONE_REACHED, { + userId: streak.userId, + milestone, + reward: bonusReward, + }); + streak.lastMilestoneReached = milestone; + await this.streakRepository.save(streak); + this.logger.log(`User ${streak.userId} reached ${milestone}-day streak milestone`); + break; + } + } + } +} \ No newline at end of file diff --git a/src/gamification/providers/daily-streak.service.spec.ts b/src/gamification/providers/daily-streak.service.spec.ts index 18003a8..810b6f7 100644 --- a/src/gamification/providers/daily-streak.service.spec.ts +++ b/src/gamification/providers/daily-streak.service.spec.ts @@ -1,271 +1,85 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { NotFoundException } from '@nestjs/common'; -import { PuzzleService } from 'src/puzzle/puzzle.service'; -import { Puzzle } from 'src/puzzle/entities/puzzle.entity'; -import { PuzzleSubmission } from 'src/puzzle/entities/puzzle-submission.entity'; -import { PuzzleProgress } from 'src/puzzle/entities/puzzle-progress.entity'; -import { User } from 'src/users/user.entity'; -import { SubmitPuzzleDto } from 'src/puzzle/dto/puzzle.dto'; +import { DailyStreakService } from './daily-streak.service'; +import { UpdateStreakService } from './update-streak.service'; +import { GetStreakService } from './get-streak.service'; +import { GetStreakLeaderboardService } from './get-streak-leaderboard.service'; +import { GetStreakStatsService } from './get-streak-stats.service'; -describe('PuzzleService', () => { - let service: PuzzleService; +const mockUpdateStreakService = { updateStreak: jest.fn() }; +const mockGetStreakService = { getStreak: jest.fn() }; +const mockGetStreakLeaderboardService = { getStreakLeaderboard: jest.fn() }; +const mockGetStreakStatsService = { getStreakStats: jest.fn() }; - const mockPuzzleRepository = { - findOne: jest.fn(), - createQueryBuilder: jest.fn(() => ({ - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn(), - })), - }; - - const mockSubmissionRepository = { - create: jest.fn(), - save: jest.fn(), - findOne: jest.fn(), - }; - - const mockProgressRepository = { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - find: jest.fn(), - }; - - const mockUserRepository = { - findOne: jest.fn(), - save: jest.fn(), - }; - - const mockEventEmitter = { - emit: jest.fn(), - }; +describe('DailyStreakService', () => { + let service: DailyStreakService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - PuzzleService, - { provide: getRepositoryToken(Puzzle), useValue: mockPuzzleRepository }, + DailyStreakService, + { provide: UpdateStreakService, useValue: mockUpdateStreakService }, + { provide: GetStreakService, useValue: mockGetStreakService }, { - provide: getRepositoryToken(PuzzleSubmission), - useValue: mockSubmissionRepository, + provide: GetStreakLeaderboardService, + useValue: mockGetStreakLeaderboardService, }, - { - provide: getRepositoryToken(PuzzleProgress), - useValue: mockProgressRepository, - }, - { provide: getRepositoryToken(User), useValue: mockUserRepository }, - { provide: EventEmitter2, useValue: mockEventEmitter }, + { provide: GetStreakStatsService, useValue: mockGetStreakStatsService }, ], }).compile(); - service = module.get(PuzzleService); - }); - - afterEach(() => { + service = module.get(DailyStreakService); jest.clearAllMocks(); }); - describe('submitPuzzleSolution', () => { - const userId = 'user-1'; - const puzzleId = 123; - const submitDto: SubmitPuzzleDto = { puzzleId, userId, solution: 'answer' }; - - const puzzle = { - id: puzzleId, - type: 'logic', - difficulty: 'easy', - solution: 'answer', - }; - - const user = { - id: userId, - xp: 100, - level: 1, - }; - - it('should successfully submit a correct puzzle solution', async () => { - const submission = { - id: 1, - user, - puzzle, - solution: submitDto.solution, - isCorrect: true, - createdAt: new Date(), - skipped: false, - }; - - mockPuzzleRepository.findOne.mockResolvedValueOnce(puzzle); // puzzle - mockUserRepository.findOne.mockResolvedValueOnce(user); // user - mockSubmissionRepository.create.mockReturnValue(submission); - mockSubmissionRepository.save.mockResolvedValue(submission); - mockSubmissionRepository.findOne.mockResolvedValueOnce(null); // no previous correct submission - mockProgressRepository.findOne.mockResolvedValue(null); - mockProgressRepository.create.mockReturnValue({ - userId, - puzzleType: puzzle.type, - completedCount: 0, - }); - mockProgressRepository.save.mockResolvedValue({}); - mockUserRepository.save.mockResolvedValue({ ...user, xp: 200, level: 1 }); - - const result = await service.submitPuzzleSolution( - userId, - puzzleId, - submitDto, - ); - - expect(result.success).toBe(true); - expect(result.xpEarned).toBe(100); - expect(result.tokensEarned).toBe(10); - expect(mockEventEmitter.emit).toHaveBeenCalledWith( - 'puzzle.submitted', - expect.objectContaining({ - userId, - puzzleId, - isCorrect: true, - }), - ); - }); - - it('should handle incorrect puzzle solution', async () => { - const incorrectDto = { solution: 'wrong' }; - - mockPuzzleRepository.findOne.mockResolvedValue(puzzle); - mockUserRepository.findOne.mockResolvedValue(user); - mockSubmissionRepository.create.mockReturnValue({ - id: 2, - user, - puzzle, - solution: incorrectDto.solution, - isCorrect: false, - createdAt: new Date(), - skipped: false, - }); - mockSubmissionRepository.save.mockResolvedValue({}); - mockSubmissionRepository.findOne.mockResolvedValue(null); - - const result = await service.submitPuzzleSolution( - userId, - puzzleId, - submitDto, - ); - - expect(result.success).toBe(false); - expect(result.message).toBe('Incorrect solution. Try again!'); - }); - - it('should throw NotFoundException if puzzle not found', async () => { - mockPuzzleRepository.findOne.mockResolvedValue(null); - - await expect( - service.submitPuzzleSolution(userId, puzzleId, submitDto), - ).rejects.toThrow(NotFoundException); - }); - - it('should throw NotFoundException if user not found', async () => { - mockPuzzleRepository.findOne.mockResolvedValue(puzzle); - mockUserRepository.findOne.mockResolvedValue(null); - - await expect( - service.submitPuzzleSolution(userId, puzzleId, submitDto), - ).rejects.toThrow(NotFoundException); - }); - - it('should return already solved response if duplicate correct submission', async () => { - const newSubmission = { - id: 99, - user, - puzzle, - solution: submitDto.solution, - isCorrect: true, - createdAt: new Date(), - skipped: false, - }; - - const existingSuccess = { - id: 1, - user, - puzzle, - isCorrect: true, - }; - - mockPuzzleRepository.findOne.mockResolvedValue(puzzle); - mockUserRepository.findOne.mockResolvedValue(user); - mockSubmissionRepository.create.mockReturnValue(newSubmission); - mockSubmissionRepository.save.mockResolvedValue(newSubmission); - mockSubmissionRepository.findOne.mockResolvedValue(existingSuccess); - - const result = await service.submitPuzzleSolution( - userId, - puzzleId, - submitDto, - ); - - expect(result.success).toBe(true); - expect(result.xpEarned).toBe(0); - expect(result.tokensEarned).toBe(0); - expect(result.message).toBe('Puzzle already solved!'); + describe('updateStreak', () => { + it('should delegate to UpdateStreakService', async () => { + const userId = '1'; + const mockResult = { streakCount: 1, hasSolvedToday: true }; + mockUpdateStreakService.updateStreak.mockResolvedValue(mockResult); + const result = await service.updateStreak(userId); + expect(mockUpdateStreakService.updateStreak).toHaveBeenCalledWith(userId); + expect(result).toBe(mockResult); }); }); - describe('getPuzzle', () => { - it('should return the puzzle if found', async () => { - const puzzle = { id: 1, title: 'Test' }; - mockPuzzleRepository.findOne.mockResolvedValue(puzzle); - - const result = await service.getPuzzle(1); - - expect(result).toEqual(puzzle); - }); - - it('should throw if puzzle not found', async () => { - mockPuzzleRepository.findOne.mockResolvedValue(null); - - await expect(service.getPuzzle(1)).rejects.toThrow(NotFoundException); + describe('getStreak', () => { + it('should delegate to GetStreakService', async () => { + const userId = '1'; + const mockResult = { streakCount: 5, hasSolvedToday: true }; + mockGetStreakService.getStreak.mockResolvedValue(mockResult); + const result = await service.getStreak(userId); + expect(mockGetStreakService.getStreak).toHaveBeenCalledWith(userId); + expect(result).toBe(mockResult); }); }); - describe('getPuzzles', () => { - it('should apply filters and return puzzles', async () => { - const puzzles = [{ id: 1 }, { id: 2 }]; - const mockQueryBuilder = { - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(puzzles), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - getRawMany: jest.fn(), - getRawOne: jest.fn(), - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - leftJoin: jest.fn().mockReturnThis(), - leftJoinAndSelect: jest.fn().mockReturnThis(), - }; - - mockPuzzleRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); - - const result = await service.getPuzzles({ - type: 'logic', - difficulty: 'easy', - }); - - expect(result).toEqual(puzzles); - expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(2); + describe('getStreakLeaderboard', () => { + it('should delegate to GetStreakLeaderboardService', async () => { + const query = { page: 1, limit: 10 }; + const mockResult = { entries: [], total: 0, page: 1, limit: 10 }; + mockGetStreakLeaderboardService.getStreakLeaderboard.mockResolvedValue( + mockResult, + ); + const result = await service.getStreakLeaderboard(query); + expect( + mockGetStreakLeaderboardService.getStreakLeaderboard, + ).toHaveBeenCalledWith(query); + expect(result).toBe(mockResult); }); }); - describe('getUserProgress', () => { - it('should return user progress', async () => { - const progress = [{ id: 1, completedCount: 2 }]; - mockProgressRepository.find.mockResolvedValue(progress); - - const result = await service.getUserProgress('user-1'); - - expect(result).toEqual(progress); + describe('getStreakStats', () => { + it('should delegate to GetStreakStatsService', async () => { + const mockResult = { + totalUsers: 100, + activeUsers: 50, + averageStreak: 6, + topStreak: 30, + }; + mockGetStreakStatsService.getStreakStats.mockResolvedValue(mockResult); + const result = await service.getStreakStats(); + expect(mockGetStreakStatsService.getStreakStats).toHaveBeenCalled(); + expect(result).toBe(mockResult); }); }); }); diff --git a/src/gamification/providers/daily-streak.service.ts b/src/gamification/providers/daily-streak.service.ts index 0cd8ea9..2466330 100644 --- a/src/gamification/providers/daily-streak.service.ts +++ b/src/gamification/providers/daily-streak.service.ts @@ -1,244 +1,38 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { DailyStreak } from '../entities/daily-streak.entity'; -import { User } from '../../users/user.entity'; -import { StreakResponseDto, StreakLeaderboardEntryDto, StreakLeaderboardResponseDto, StreakQueryDto } from '../dto/streak.dto'; -import { STREAK_MILESTONES, STREAK_EVENTS, STREAK_CONFIG } from '../constants/streak.constants'; -import { BonusRewardDto } from '../dto/bonus-reward.dto'; +import { Injectable } from '@nestjs/common'; +import { UpdateStreakService } from './update-streak.service'; +import { GetStreakService } from './get-streak.service'; +import { GetStreakLeaderboardService } from './get-streak-leaderboard.service'; +import { GetStreakStatsService } from './get-streak-stats.service'; +import { + StreakResponseDto, + StreakLeaderboardResponseDto, + StreakQueryDto, +} from '../dto/streak.dto'; @Injectable() export class DailyStreakService { - private readonly logger = new Logger(DailyStreakService.name); - constructor( - @InjectRepository(DailyStreak) - private readonly streakRepository: Repository, - @InjectRepository(User) - private readonly userRepository: Repository, - private readonly eventEmitter: EventEmitter2, + private readonly updateStreakService: UpdateStreakService, + private readonly getStreakService: GetStreakService, + private readonly getStreakLeaderboardService: GetStreakLeaderboardService, + private readonly getStreakStatsService: GetStreakStatsService, ) {} - /** - * Update user's streak after solving a puzzle - */ async updateStreak(userId: string): Promise { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - // Find or create streak record - let streak = await this.streakRepository.findOne({ - where: { userId }, - relations: ['user'], - }); - - if (!streak) { - // Create new streak record - streak = this.streakRepository.create({ - userId, - lastActiveDate: today, - streakCount: 1, - longestStreak: 1, - lastMilestoneReached: null as number | null, - }); - } else { - const lastActive = new Date(streak.lastActiveDate); - lastActive.setHours(0, 0, 0, 0); - - const daysDifference = Math.floor((today.getTime() - lastActive.getTime()) / (1000 * 60 * 60 * 24)); - - if (daysDifference === 0) { - // User already solved a puzzle today, return current streak - return this.buildStreakResponse(streak); - } else if (daysDifference === 1) { - // Consecutive day, increment streak - streak.streakCount += 1; - streak.lastActiveDate = today; - } else { - // Streak broken, reset to 1 - streak.streakCount = 1; - streak.lastActiveDate = today; - } - - // Update longest streak if current streak is longer - if (streak.streakCount > streak.longestStreak) { - streak.longestStreak = streak.streakCount; - } - } - - // Save the streak - const savedStreak = await this.streakRepository.save(streak); - - // Check for milestones - await this.checkAndAwardMilestones(savedStreak); - - // Emit puzzle solved event - this.eventEmitter.emit(STREAK_EVENTS.PUZZLE_SOLVED, { - userId, - streakCount: savedStreak.streakCount, - isNewStreak: !streak.id, - }); - - this.logger.log(`Updated streak for user ${userId}: ${savedStreak.streakCount} days`); - - return this.buildStreakResponse(savedStreak); + return this.updateStreakService.updateStreak(userId); } - /** - * Get current streak status for a user - */ async getStreak(userId: string): Promise { - const streak = await this.streakRepository.findOne({ - where: { userId }, - relations: ['user'], - }); - - if (!streak) { - // Return default streak response for new users - return { - streakCount: 0, - longestStreak: 0, - lastActiveDate: null as Date | null, - hasSolvedToday: false, - nextMilestone: 3, - daysUntilNextMilestone: 3, - }; - } - - return this.buildStreakResponse(streak); - } - - /** - * Get streak leaderboard - */ - async getStreakLeaderboard(query: StreakQueryDto): Promise { - const { page = 1, limit = 10 } = query; - const skip = (page - 1) * limit; - - const [entries, total] = await this.streakRepository - .createQueryBuilder('streak') - .leftJoinAndSelect('streak.user', 'user') - .select([ - 'streak.userId', - 'streak.streakCount', - 'streak.longestStreak', - 'streak.lastActiveDate', - 'user.username', - ]) - .orderBy('streak.streakCount', 'DESC') - .addOrderBy('streak.longestStreak', 'DESC') - .addOrderBy('streak.lastActiveDate', 'DESC') - .skip(skip) - .take(limit) - .getManyAndCount(); - - const leaderboardEntries: StreakLeaderboardEntryDto[] = entries.map(entry => ({ - userId: entry.userId, - username: entry.user?.username || `User ${entry.userId}`, - streakCount: entry.streakCount, - longestStreak: entry.longestStreak, - lastActiveDate: entry.lastActiveDate, - })); - - return { - entries: leaderboardEntries, - total, - page, - limit, - }; - } - - /** - * Check and award milestones - */ - private async checkAndAwardMilestones(streak: DailyStreak): Promise { - const milestones = Object.keys(STREAK_MILESTONES).map(Number).sort((a, b) => a - b); - - for (const milestone of milestones) { - if (streak.streakCount >= milestone && - (!streak.lastMilestoneReached || streak.lastMilestoneReached < milestone)) { - - const milestoneConfig = STREAK_MILESTONES[milestone as keyof typeof STREAK_MILESTONES]; - - // Award bonus rewards - const bonusReward: BonusRewardDto = { - userId: streak.userId, - bonusXp: milestoneConfig.xp, - bonusTokens: milestoneConfig.tokens, - reason: milestoneConfig.description, - }; - - this.eventEmitter.emit(STREAK_EVENTS.MILESTONE_REACHED, { - userId: streak.userId, - milestone, - reward: bonusReward, - }); - - // Update last milestone reached - streak.lastMilestoneReached = milestone; - await this.streakRepository.save(streak); - - this.logger.log(`User ${streak.userId} reached ${milestone}-day streak milestone`); - break; // Only award the highest milestone reached - } - } + return this.getStreakService.getStreak(userId); } - /** - * Build streak response DTO - */ - private buildStreakResponse(streak: DailyStreak): StreakResponseDto { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const lastActive = new Date(streak.lastActiveDate); - lastActive.setHours(0, 0, 0, 0); - - const hasSolvedToday = today.getTime() === lastActive.getTime(); - - // Find next milestone - const milestones = Object.keys(STREAK_MILESTONES).map(Number).sort((a, b) => a - b); - const nextMilestone = milestones.find(m => m > streak.streakCount); - const daysUntilNextMilestone = nextMilestone ? nextMilestone - streak.streakCount : null; - - return { - streakCount: streak.streakCount, - longestStreak: streak.longestStreak, - lastActiveDate: streak.lastActiveDate, - hasSolvedToday, - nextMilestone, - daysUntilNextMilestone: daysUntilNextMilestone || undefined, - }; + async getStreakLeaderboard( + query: StreakQueryDto, + ): Promise { + return this.getStreakLeaderboardService.getStreakLeaderboard(query); } - /** - * Get streak statistics for admin purposes - */ - async getStreakStats(): Promise<{ - totalUsers: number; - activeUsers: number; - averageStreak: number; - topStreak: number; - }> { - const totalUsers = await this.userRepository.count(); - const activeUsers = await this.streakRepository.count(); - - const avgResult = await this.streakRepository - .createQueryBuilder('streak') - .select('AVG(streak.streakCount)', 'average') - .getRawOne(); - - const topResult = await this.streakRepository - .createQueryBuilder('streak') - .select('MAX(streak.streakCount)', 'max') - .getRawOne(); - - return { - totalUsers, - activeUsers, - averageStreak: Math.round(avgResult?.average || 0), - topStreak: topResult?.max || 0, - }; + async getStreakStats() { + return this.getStreakStatsService.getStreakStats(); } -} \ No newline at end of file +} diff --git a/src/gamification/providers/get-streak-leaderboard.service.ts b/src/gamification/providers/get-streak-leaderboard.service.ts new file mode 100644 index 0000000..b029212 --- /dev/null +++ b/src/gamification/providers/get-streak-leaderboard.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { + StreakLeaderboardEntryDto, + StreakLeaderboardResponseDto, + StreakQueryDto, +} from '../dto/streak.dto'; + +@Injectable() +export class GetStreakLeaderboardService { + constructor( + @InjectRepository(DailyStreak) + private readonly streakRepository: Repository, + ) {} + + async getStreakLeaderboard( + query: StreakQueryDto, + ): Promise { + const { page = 1, limit = 10 } = query; + const skip = (page - 1) * limit; + const [entries, total] = await this.streakRepository + .createQueryBuilder('streak') + .leftJoinAndSelect('streak.user', 'user') + .select([ + 'streak.userId', + 'streak.streakCount', + 'streak.longestStreak', + 'streak.lastActiveDate', + 'user.username', + ]) + .orderBy('streak.streakCount', 'DESC') + .addOrderBy('streak.longestStreak', 'DESC') + .addOrderBy('streak.lastActiveDate', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + const leaderboardEntries: StreakLeaderboardEntryDto[] = entries.map( + (entry) => ({ + userId: entry.userId, + username: entry.user?.username || `User ${entry.userId}`, + streakCount: entry.streakCount, + longestStreak: entry.longestStreak, + lastActiveDate: entry.lastActiveDate, + }), + ); + return { + entries: leaderboardEntries, + total, + page, + limit, + }; + } +} diff --git a/src/gamification/providers/get-streak-stats.service.ts b/src/gamification/providers/get-streak-stats.service.ts new file mode 100644 index 0000000..e9c9b37 --- /dev/null +++ b/src/gamification/providers/get-streak-stats.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { User } from '../../users/user.entity'; + +@Injectable() +export class GetStreakStatsService { + constructor( + @InjectRepository(DailyStreak) + private readonly streakRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async getStreakStats(): Promise<{ + totalUsers: number; + activeUsers: number; + averageStreak: number; + topStreak: number; + }> { + const totalUsers = await this.userRepository.count(); + const activeUsers = await this.streakRepository.count(); + const avgResult = await this.streakRepository + .createQueryBuilder('streak') + .select('AVG(streak.streakCount)', 'average') + .getRawOne(); + const topResult = await this.streakRepository + .createQueryBuilder('streak') + .select('MAX(streak.streakCount)', 'max') + .getRawOne(); + return { + totalUsers, + activeUsers, + averageStreak: Math.round(avgResult?.average || 0), + topStreak: topResult?.max || 0, + }; + } +} diff --git a/src/gamification/providers/get-streak.service.ts b/src/gamification/providers/get-streak.service.ts new file mode 100644 index 0000000..7eac84c --- /dev/null +++ b/src/gamification/providers/get-streak.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { StreakResponseDto } from '../dto/streak.dto'; +import { BuildStreakResponseService } from './build-streak-response.service'; + +@Injectable() +export class GetStreakService { + constructor( + @InjectRepository(DailyStreak) + private readonly streakRepository: Repository, + private readonly buildStreakResponseService: BuildStreakResponseService, + ) {} + + async getStreak(userId: string): Promise { + const streak = await this.streakRepository.findOne({ + where: { userId }, + relations: ['user'], + }); + if (!streak) { + return { + streakCount: 0, + longestStreak: 0, + lastActiveDate: null as Date | null, + hasSolvedToday: false, + nextMilestone: 3, + daysUntilNextMilestone: 3, + }; + } + return this.buildStreakResponseService.buildStreakResponse(streak); + } +} diff --git a/src/gamification/providers/update-streak.service.ts b/src/gamification/providers/update-streak.service.ts new file mode 100644 index 0000000..4972783 --- /dev/null +++ b/src/gamification/providers/update-streak.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { DailyStreak } from '../entities/daily-streak.entity'; +import { User } from '../../users/user.entity'; +import { StreakResponseDto } from '../dto/streak.dto'; +import { STREAK_EVENTS } from '../constants/streak.constants'; +import { CheckAndAwardMilestonesService } from './check-and-award-milestones.service'; +import { BuildStreakResponseService } from './build-streak-response.service'; + +@Injectable() +export class UpdateStreakService { + private readonly logger = new Logger(UpdateStreakService.name); + + constructor( + @InjectRepository(DailyStreak) + private readonly streakRepository: Repository, + private readonly eventEmitter: EventEmitter2, + private readonly checkAndAwardMilestonesService: CheckAndAwardMilestonesService, + private readonly buildStreakResponseService: BuildStreakResponseService, + ) {} + + async updateStreak(userId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + let streak = await this.streakRepository.findOne({ + where: { userId }, + relations: ['user'], + }); + + if (!streak) { + streak = this.streakRepository.create({ + userId, + lastActiveDate: today, + streakCount: 1, + longestStreak: 1, + lastMilestoneReached: null as number | null, + }); + } else { + const lastActive = new Date(streak.lastActiveDate); + lastActive.setHours(0, 0, 0, 0); + const daysDifference = Math.floor( + (today.getTime() - lastActive.getTime()) / (1000 * 60 * 60 * 24), + ); + if (daysDifference === 0) { + return this.buildStreakResponseService.buildStreakResponse(streak); + } else if (daysDifference === 1) { + streak.streakCount += 1; + streak.lastActiveDate = today; + } else { + streak.streakCount = 1; + streak.lastActiveDate = today; + } + if (streak.streakCount > streak.longestStreak) { + streak.longestStreak = streak.streakCount; + } + } + const savedStreak = await this.streakRepository.save(streak); + await this.checkAndAwardMilestonesService.checkAndAwardMilestones( + savedStreak, + ); + this.eventEmitter.emit(STREAK_EVENTS.PUZZLE_SOLVED, { + userId, + streakCount: savedStreak.streakCount, + isNewStreak: !streak.id, + }); + this.logger.log( + `Updated streak for user ${userId}: ${savedStreak.streakCount} days`, + ); + return this.buildStreakResponseService.buildStreakResponse(savedStreak); + } +} diff --git a/src/puzzle/puzzle.service.spec.ts b/src/puzzle/puzzle.service.spec.ts index b509676..b5dcce3 100644 --- a/src/puzzle/puzzle.service.spec.ts +++ b/src/puzzle/puzzle.service.spec.ts @@ -12,12 +12,28 @@ import { SubmitPuzzleDto } from './dto/puzzle.dto'; describe('PuzzleService', () => { let service: PuzzleService; + let puzzleRepository: any; + let submissionRepository: any; + let progressRepository: any; + let userRepository: any; + let eventEmitter: any; const mockPuzzleRepository = { findOne: jest.fn(), createQueryBuilder: jest.fn(() => ({ andWhere: jest.fn().mockReturnThis(), getMany: jest.fn(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + getRawOne: jest.fn(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), })), }; @@ -58,22 +74,21 @@ describe('PuzzleService', () => { }, { provide: getRepositoryToken(User), useValue: mockUserRepository }, { provide: EventEmitter2, useValue: mockEventEmitter }, - { - provide: getRepositoryToken(User), - useValue: { - findOne: jest.fn().mockResolvedValue(mockUserRepository), // or null to test NotFound - }, - }, - { - provide: getRepositoryToken(Puzzle), - useValue: { - findOne: jest.fn().mockResolvedValue(mockPuzzleRepository), // or null to test NotFound - }, - } ], }).compile(); service = module.get(PuzzleService); + puzzleRepository = module.get>( + getRepositoryToken(Puzzle), + ); + submissionRepository = module.get>( + getRepositoryToken(PuzzleSubmission), + ); + progressRepository = module.get>( + getRepositoryToken(PuzzleProgress), + ); + userRepository = module.get>(getRepositoryToken(User)); + eventEmitter = module.get(EventEmitter2); }); afterEach(() => { @@ -129,6 +144,15 @@ describe('PuzzleService', () => { submitDto, ); + expect(mockPuzzleRepository.findOne).toHaveBeenCalledWith({ + where: { id: puzzleId }, + }); + expect(mockSubmissionRepository.create).toHaveBeenCalledWith({ + userId, + puzzleId, + submitDto, + }); + expect(result.success).toBe(true); expect(result.xpEarned).toBe(100); expect(result.tokensEarned).toBe(10); @@ -143,7 +167,7 @@ describe('PuzzleService', () => { }); it('should handle incorrect puzzle solution', async () => { - const incorrectDto = { solution: 'wrong' }; + const incorrectDto = { userId, puzzleId, solution: 'wrong' }; mockPuzzleRepository.findOne.mockResolvedValue(puzzle); mockUserRepository.findOne.mockResolvedValue(user); @@ -162,9 +186,15 @@ describe('PuzzleService', () => { const result = await service.submitPuzzleSolution( userId, puzzleId, - submitDto, + incorrectDto, ); + expect(mockEventEmitter.emit).toHaveBeenCalledWith('puzzle.submitted', { + userId, + puzzleId, + isCorrect: false, + timestamp: expect.any(Date), + }); expect(result.success).toBe(false); expect(result.message).toBe('Incorrect solution. Try again!'); }); @@ -272,13 +302,32 @@ describe('PuzzleService', () => { }); describe('getUserProgress', () => { - it('should return user progress', async () => { - const progress = [{ id: 1, completedCount: 2 }]; + it('should return user puzzle progress', async () => { + const progress = [ + { + id: 1, + userId: '1', + puzzleType: 'logic', + completedCount: 5, + total: 10, + }, + { + id: 2, + userId: '1', + puzzleType: 'coding', + completedCount: 3, + total: 8, + }, + ]; + mockProgressRepository.find.mockResolvedValue(progress); - const result = await service.getUserProgress('user-1'); + const result = await service.getUserProgress('1'); expect(result).toEqual(progress); + expect(mockProgressRepository.find).toHaveBeenCalledWith({ + where: { userId: '1' }, + }); }); }); });