From 0fe1d45e2c5bc8fdb314c102c94a7245c327e090 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Sat, 12 Jul 2025 16:05:25 +0100 Subject: [PATCH 1/2] implementation of Refactor of all business logic in gamification module --- .../analytics.controller.breakdown.spec.ts | 24 +- .../controllers/streak.controller.spec.ts | 23 +- src/gamification/gamification.module.ts | 18 +- .../build-streak-response.service.ts | 26 ++ .../check-and-award-milestones.service.ts | 43 ++ .../providers/daily-streak.service.spec.ts | 371 +++--------------- .../providers/daily-streak.service.ts | 252 ++---------- .../get-streak-leaderboard.service.ts | 55 +++ .../providers/get-streak-stats.service.ts | 39 ++ .../providers/get-streak.service.ts | 33 ++ .../providers/update-streak.service.ts | 74 ++++ src/puzzle/puzzle.service.spec.ts | 47 ++- 12 files changed, 416 insertions(+), 589 deletions(-) create mode 100644 src/gamification/providers/build-streak-response.service.ts create mode 100644 src/gamification/providers/check-and-award-milestones.service.ts create mode 100644 src/gamification/providers/get-streak-leaderboard.service.ts create mode 100644 src/gamification/providers/get-streak-stats.service.ts create mode 100644 src/gamification/providers/get-streak.service.ts create mode 100644 src/gamification/providers/update-streak.service.ts diff --git a/src/analytics/analytics.controller.breakdown.spec.ts b/src/analytics/analytics.controller.breakdown.spec.ts index 1df789e..c21930f 100644 --- a/src/analytics/analytics.controller.breakdown.spec.ts +++ b/src/analytics/analytics.controller.breakdown.spec.ts @@ -3,7 +3,7 @@ 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 } from './dto/get-analytics-query.dto'; +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'; @@ -99,7 +99,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { describe('getBreakdown', () => { it('should return analytics breakdown', async () => { const query: GetAnalyticsQueryDto = { - timeFilter: 'weekly', + timeFilter: TimeFilter.WEEKLY, userId: '123e4567-e89b-12d3-a456-426614174000', }; @@ -123,7 +123,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { }); it('should handle service errors', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; const error = new Error('Service error'); analyticsBreakdownService.getBreakdown.mockRejectedValue(error); @@ -134,7 +134,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { describe('getTopEventTypes', () => { it('should return top event types with default limit', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); @@ -145,7 +145,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { }); it('should return top event types with custom limit', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; const limit = 5; analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); @@ -157,7 +157,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { }); it('should clamp limit to minimum value', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; const limit = 0; analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); @@ -169,7 +169,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { }); it('should clamp limit to maximum value', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; const limit = 100; analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); @@ -181,7 +181,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { }); it('should handle service errors', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; const error = new Error('Service error'); analyticsBreakdownService.getTopEventTypes.mockRejectedValue(error); @@ -220,7 +220,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { describe('query parameter validation', () => { it('should handle all query parameters correctly', async () => { const query: GetAnalyticsQueryDto = { - timeFilter: 'monthly', + timeFilter: TimeFilter.MONTHLY, from: '2024-01-01T00:00:00Z', to: '2024-01-31T23:59:59Z', userId: '123e4567-e89b-12d3-a456-426614174000', @@ -237,7 +237,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { it('should handle partial query parameters', async () => { const query: GetAnalyticsQueryDto = { - timeFilter: 'weekly', + timeFilter: TimeFilter.WEEKLY, userId: '123e4567-e89b-12d3-a456-426614174000', }; @@ -252,7 +252,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { describe('response structure validation', () => { it('should return properly structured breakdown response', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); @@ -266,7 +266,7 @@ describe('AnalyticsController - Breakdown Endpoints', () => { }); it('should return properly structured top event types', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: 'weekly' }; + const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); 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 ad498b6..b1f60fc 100644 --- a/src/gamification/providers/daily-streak.service.spec.ts +++ b/src/gamification/providers/daily-streak.service.spec.ts @@ -1,365 +1,84 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { DailyStreakService } from './daily-streak.service'; -import { DailyStreak } from '../entities/daily-streak.entity'; -import { User } from '../../users/user.entity'; -import { STREAK_MILESTONES } from '../constants/streak.constants'; +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'; + +const mockUpdateStreakService = { updateStreak: jest.fn() }; +const mockGetStreakService = { getStreak: jest.fn() }; +const mockGetStreakLeaderboardService = { getStreakLeaderboard: jest.fn() }; +const mockGetStreakStatsService = { getStreakStats: jest.fn() }; describe('DailyStreakService', () => { let service: DailyStreakService; - let streakRepository: Repository; - let userRepository: Repository; - let eventEmitter: EventEmitter2; - - const mockStreakRepository = { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - createQueryBuilder: jest.fn(() => ({ - leftJoinAndSelect: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - addOrderBy: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - getManyAndCount: jest.fn(), - select: jest.fn().mockReturnThis(), - getRawOne: jest.fn(), - })), - }; - - const mockUserRepository = { - count: jest.fn(), - }; - - const mockEventEmitter = { - emit: jest.fn(), - }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ DailyStreakService, + { provide: UpdateStreakService, useValue: mockUpdateStreakService }, + { provide: GetStreakService, useValue: mockGetStreakService }, { - provide: getRepositoryToken(DailyStreak), - useValue: mockStreakRepository, - }, - { - provide: getRepositoryToken(User), - useValue: mockUserRepository, - }, - { - provide: EventEmitter2, - useValue: mockEventEmitter, + provide: GetStreakLeaderboardService, + useValue: mockGetStreakLeaderboardService, }, + { provide: GetStreakStatsService, useValue: mockGetStreakStatsService }, ], }).compile(); - service = module.get(DailyStreakService); - streakRepository = module.get>(getRepositoryToken(DailyStreak)); - userRepository = module.get>(getRepositoryToken(User)); - eventEmitter = module.get(EventEmitter2); - }); - - afterEach(() => { jest.clearAllMocks(); }); describe('updateStreak', () => { - it('should create new streak for first-time user', async () => { - const userId = 1; - const today = new Date(); - today.setHours(0, 0, 0, 0); - - mockStreakRepository.findOne.mockResolvedValue(null); - mockStreakRepository.create.mockReturnValue({ - userId, - lastActiveDate: today, - streakCount: 1, - longestStreak: 1, - lastMilestoneReached: null, - }); - mockStreakRepository.save.mockResolvedValue({ - id: 1, - userId, - lastActiveDate: today, - streakCount: 1, - longestStreak: 1, - lastMilestoneReached: null, - }); - + 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(mockStreakRepository.findOne).toHaveBeenCalledWith({ - where: { userId }, - relations: ['user'], - }); - expect(mockStreakRepository.create).toHaveBeenCalledWith({ - userId, - lastActiveDate: today, - streakCount: 1, - longestStreak: 1, - lastMilestoneReached: null, - }); - expect(mockStreakRepository.save).toHaveBeenCalled(); - expect(mockEventEmitter.emit).toHaveBeenCalledWith('streak.puzzle.solved', { - userId, - streakCount: 1, - isNewStreak: true, - }); - expect(result.streakCount).toBe(1); - expect(result.hasSolvedToday).toBe(true); - }); - - it('should increment streak for consecutive day', async () => { - const userId = 1; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - const existingStreak = { - id: 1, - userId, - lastActiveDate: yesterday, - streakCount: 3, - longestStreak: 3, - lastMilestoneReached: null, - }; - - mockStreakRepository.findOne.mockResolvedValue(existingStreak); - mockStreakRepository.save.mockResolvedValue({ - ...existingStreak, - lastActiveDate: today, - streakCount: 4, - longestStreak: 4, - }); - - const result = await service.updateStreak(userId); - - expect(mockStreakRepository.save).toHaveBeenCalledWith({ - ...existingStreak, - lastActiveDate: today, - streakCount: 4, - longestStreak: 4, - }); - expect(result.streakCount).toBe(4); - expect(result.hasSolvedToday).toBe(true); - }); - - it('should reset streak when day is skipped', async () => { - const userId = 1; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const twoDaysAgo = new Date(today); - twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); - - const existingStreak = { - id: 1, - userId, - lastActiveDate: twoDaysAgo, - streakCount: 5, - longestStreak: 5, - lastMilestoneReached: null, - }; - - mockStreakRepository.findOne.mockResolvedValue(existingStreak); - mockStreakRepository.save.mockResolvedValue({ - ...existingStreak, - lastActiveDate: today, - streakCount: 1, - longestStreak: 5, // Should remain the same - }); - - const result = await service.updateStreak(userId); - - expect(mockStreakRepository.save).toHaveBeenCalledWith({ - ...existingStreak, - lastActiveDate: today, - streakCount: 1, - longestStreak: 5, - }); - expect(result.streakCount).toBe(1); - expect(result.hasSolvedToday).toBe(true); - }); - - it('should not update streak if already solved today', async () => { - const userId = 1; - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const existingStreak = { - id: 1, - userId, - lastActiveDate: today, - streakCount: 3, - longestStreak: 3, - lastMilestoneReached: null, - }; - - mockStreakRepository.findOne.mockResolvedValue(existingStreak); - - const result = await service.updateStreak(userId); - - expect(mockStreakRepository.save).not.toHaveBeenCalled(); - expect(mockEventEmitter.emit).not.toHaveBeenCalled(); - expect(result.streakCount).toBe(3); - expect(result.hasSolvedToday).toBe(true); - }); - - it('should award milestone rewards when reaching milestones', async () => { - const userId = 1; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - const existingStreak = { - id: 1, - userId, - lastActiveDate: yesterday, - streakCount: 2, - longestStreak: 2, - lastMilestoneReached: null, - }; - - mockStreakRepository.findOne.mockResolvedValue(existingStreak); - mockStreakRepository.save.mockResolvedValue({ - ...existingStreak, - lastActiveDate: today, - streakCount: 3, - longestStreak: 3, - lastMilestoneReached: 3, - }); - - const result = await service.updateStreak(userId); - - expect(mockEventEmitter.emit).toHaveBeenCalledWith('streak.milestone.reached', { - userId, - milestone: 3, - reward: { - userId, - bonusXp: STREAK_MILESTONES[3].xp, - bonusTokens: STREAK_MILESTONES[3].tokens, - reason: STREAK_MILESTONES[3].description, - }, - }); - expect(result.streakCount).toBe(3); + expect(mockUpdateStreakService.updateStreak).toHaveBeenCalledWith(userId); + expect(result).toBe(mockResult); }); }); describe('getStreak', () => { - it('should return streak data for existing user', async () => { - const userId = 1; - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const existingStreak = { - id: 1, - userId, - lastActiveDate: today, - streakCount: 5, - longestStreak: 10, - lastMilestoneReached: 3, - }; - - mockStreakRepository.findOne.mockResolvedValue(existingStreak); - - const result = await service.getStreak(userId); - - expect(result.streakCount).toBe(5); - expect(result.longestStreak).toBe(10); - expect(result.hasSolvedToday).toBe(true); - expect(result.nextMilestone).toBe(7); - expect(result.daysUntilNextMilestone).toBe(2); - }); - - it('should return default data for new user', async () => { - const userId = 1; - - mockStreakRepository.findOne.mockResolvedValue(null); - + 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(result.streakCount).toBe(0); - expect(result.longestStreak).toBe(0); - expect(result.lastActiveDate).toBeNull(); - expect(result.hasSolvedToday).toBe(false); - expect(result.nextMilestone).toBe(3); - expect(result.daysUntilNextMilestone).toBe(3); + expect(mockGetStreakService.getStreak).toHaveBeenCalledWith(userId); + expect(result).toBe(mockResult); }); }); describe('getStreakLeaderboard', () => { - it('should return leaderboard with pagination', async () => { + it('should delegate to GetStreakLeaderboardService', async () => { const query = { page: 1, limit: 10 }; - const mockEntries = [ - { - userId: 1, - streakCount: 10, - longestStreak: 15, - lastActiveDate: new Date(), - user: { username: 'user1' }, - }, - { - userId: 2, - streakCount: 8, - longestStreak: 12, - lastActiveDate: new Date(), - user: { username: 'user2' }, - }, - ]; - - const mockQueryBuilder = { - leftJoinAndSelect: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - addOrderBy: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - getManyAndCount: jest.fn().mockResolvedValue([mockEntries, 2]), - }; - - mockStreakRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); - + const mockResult = { entries: [], total: 0, page: 1, limit: 10 }; + mockGetStreakLeaderboardService.getStreakLeaderboard.mockResolvedValue( + mockResult, + ); const result = await service.getStreakLeaderboard(query); - - expect(result.entries).toHaveLength(2); - expect(result.total).toBe(2); - expect(result.page).toBe(1); - expect(result.limit).toBe(10); - expect(result.entries[0].userId).toBe(1); - expect(result.entries[0].username).toBe('user1'); + expect( + mockGetStreakLeaderboardService.getStreakLeaderboard, + ).toHaveBeenCalledWith(query); + expect(result).toBe(mockResult); }); }); describe('getStreakStats', () => { - it('should return streak statistics', async () => { - mockUserRepository.count.mockResolvedValue(100); - mockStreakRepository.count.mockResolvedValue(50); - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ average: 5.5 }), + it('should delegate to GetStreakStatsService', async () => { + const mockResult = { + totalUsers: 100, + activeUsers: 50, + averageStreak: 6, + topStreak: 30, }; - - const mockMaxQueryBuilder = { - select: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ max: 30 }), - }; - - mockStreakRepository.createQueryBuilder - .mockReturnValueOnce(mockQueryBuilder) - .mockReturnValueOnce(mockMaxQueryBuilder); - + mockGetStreakStatsService.getStreakStats.mockResolvedValue(mockResult); const result = await service.getStreakStats(); - - expect(result.totalUsers).toBe(100); - expect(result.activeUsers).toBe(50); - expect(result.averageStreak).toBe(6); - expect(result.topStreak).toBe(30); + expect(mockGetStreakStatsService.getStreakStats).toHaveBeenCalled(); + expect(result).toBe(mockResult); }); }); -}); \ No newline at end of file +}); 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 e72a054..7ed27cc 100644 --- a/src/puzzle/puzzle.service.spec.ts +++ b/src/puzzle/puzzle.service.spec.ts @@ -76,9 +76,15 @@ describe('PuzzleService', () => { }).compile(); service = module.get(PuzzleService); - puzzleRepository = module.get>(getRepositoryToken(Puzzle)); - submissionRepository = module.get>(getRepositoryToken(PuzzleSubmission)); - progressRepository = module.get>(getRepositoryToken(PuzzleProgress)); + puzzleRepository = module.get>( + getRepositoryToken(Puzzle), + ); + submissionRepository = module.get>( + getRepositoryToken(PuzzleSubmission), + ); + progressRepository = module.get>( + getRepositoryToken(PuzzleProgress), + ); userRepository = module.get>(getRepositoryToken(User)); eventEmitter = module.get(EventEmitter2); }); @@ -88,7 +94,7 @@ describe('PuzzleService', () => { }); describe('submitPuzzleSolution', () => { - const userId = 1; + const userId = '1'; const puzzleId = 101; const submitDto: SubmitPuzzleDto = { solution: 'correct answer' }; @@ -140,7 +146,11 @@ describe('PuzzleService', () => { level: 1, }); - const result = await service.submitPuzzleSolution(userId, puzzleId, submitDto); + const result = await service.submitPuzzleSolution( + userId, + puzzleId, + submitDto, + ); expect(mockPuzzleRepository.findOne).toHaveBeenCalledWith({ where: { id: puzzleId }, @@ -194,7 +204,11 @@ describe('PuzzleService', () => { submittedAt: new Date(), }); - const result = await service.submitPuzzleSolution(userId, puzzleId, submitDtoIncorrect); + const result = await service.submitPuzzleSolution( + userId, + puzzleId, + submitDtoIncorrect, + ); expect(mockEventEmitter.emit).toHaveBeenCalledWith('puzzle.submitted', { userId, @@ -209,8 +223,9 @@ describe('PuzzleService', () => { it('should throw NotFoundException when puzzle not found', async () => { mockPuzzleRepository.findOne.mockResolvedValue(null); - await expect(service.submitPuzzleSolution(userId, puzzleId, submitDto)) - .rejects.toThrow(NotFoundException); + await expect( + service.submitPuzzleSolution(userId, puzzleId, submitDto), + ).rejects.toThrow(NotFoundException); }); it('should handle already solved puzzle', async () => { @@ -245,7 +260,11 @@ describe('PuzzleService', () => { mockSubmissionRepository.save.mockResolvedValue(submission); mockSubmissionRepository.findOne.mockResolvedValue(previousSuccess); - const result = await service.submitPuzzleSolution(userId, puzzleId, submitDto); + const result = await service.submitPuzzleSolution( + userId, + puzzleId, + submitDto, + ); expect(result.success).toBe(true); expect(result.xpEarned).toBe(0); @@ -319,14 +338,14 @@ describe('PuzzleService', () => { const progress = [ { id: 1, - userId: 1, + userId: '1', puzzleType: 'logic', completedCount: 5, total: 10, }, { id: 2, - userId: 1, + userId: '1', puzzleType: 'coding', completedCount: 3, total: 8, @@ -335,12 +354,12 @@ describe('PuzzleService', () => { mockProgressRepository.find.mockResolvedValue(progress); - const result = await service.getUserProgress(1); + const result = await service.getUserProgress('1'); expect(result).toEqual(progress); expect(mockProgressRepository.find).toHaveBeenCalledWith({ - where: { userId: 1 }, + where: { userId: '1' }, }); }); }); -}); \ No newline at end of file +}); From ed3df709e7ed4d8c3fc019897d6e8655e614ee11 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Wed, 16 Jul 2025 11:55:55 +0100 Subject: [PATCH 2/2] fixed the errors in the puzzle service spec. --- src/puzzle/puzzle.service.spec.ts | 56 ++++++++++++++++++------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/src/puzzle/puzzle.service.spec.ts b/src/puzzle/puzzle.service.spec.ts index a5e313c..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(() => { @@ -136,7 +151,7 @@ describe('PuzzleService', () => { userId, puzzleId, submitDto, - ); + }); expect(result.success).toBe(true); expect(result.xpEarned).toBe(100); @@ -152,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); @@ -171,15 +186,7 @@ describe('PuzzleService', () => { const result = await service.submitPuzzleSolution( userId, puzzleId, - attemptData: { solution: submitDtoIncorrect.solution }, - result: false, - submittedAt: new Date(), - }); - - const result = await service.submitPuzzleSolution( - userId, - puzzleId, - submitDtoIncorrect, + incorrectDto, ); expect(mockEventEmitter.emit).toHaveBeenCalledWith('puzzle.submitted', { @@ -318,6 +325,9 @@ describe('PuzzleService', () => { const result = await service.getUserProgress('1'); expect(result).toEqual(progress); + expect(mockProgressRepository.find).toHaveBeenCalledWith({ + where: { userId: '1' }, + }); }); }); });