From 0fe1d45e2c5bc8fdb314c102c94a7245c327e090 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Sat, 12 Jul 2025 16:05:25 +0100 Subject: [PATCH 1/3] 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 e57dc91a9c0ce3bb16b7fca0303db44d8a390973 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Sat, 12 Jul 2025 21:03:36 +0100 Subject: [PATCH 2/3] Refractor badge module --- src/badge/badge.module.ts | 41 +++- src/badge/badge.service.spec.ts | 154 ++++++++++-- src/badge/badge.service.ts | 223 +++--------------- .../providers/auto-assign-badges.service.ts | 63 +++++ src/badge/providers/create-badge.service.ts | 40 ++++ .../determine-badge-for-rank.service.ts | 22 ++ .../find-all-active-badges.service.ts | 19 ++ .../providers/find-all-badges.service.ts | 18 ++ src/badge/providers/find-one-badge.service.ts | 25 ++ .../providers/get-badge-by-rank.service.ts | 18 ++ src/badge/providers/remove-badge.service.ts | 38 +++ .../providers/seed-default-badges.service.ts | 70 ++++++ src/badge/providers/update-badge.service.ts | 44 ++++ 13 files changed, 556 insertions(+), 219 deletions(-) create mode 100644 src/badge/providers/auto-assign-badges.service.ts create mode 100644 src/badge/providers/create-badge.service.ts create mode 100644 src/badge/providers/determine-badge-for-rank.service.ts create mode 100644 src/badge/providers/find-all-active-badges.service.ts create mode 100644 src/badge/providers/find-all-badges.service.ts create mode 100644 src/badge/providers/find-one-badge.service.ts create mode 100644 src/badge/providers/get-badge-by-rank.service.ts create mode 100644 src/badge/providers/remove-badge.service.ts create mode 100644 src/badge/providers/seed-default-badges.service.ts create mode 100644 src/badge/providers/update-badge.service.ts diff --git a/src/badge/badge.module.ts b/src/badge/badge.module.ts index c7dbff3..5508144 100644 --- a/src/badge/badge.module.ts +++ b/src/badge/badge.module.ts @@ -1,18 +1,37 @@ // badge.module.ts -import { Module } from "@nestjs/common" -import { TypeOrmModule } from "@nestjs/typeorm" -import { Badge } from "./entities/badge.entity" -// import { BadgeController } from "./badge.controller" -import { BadgeService } from "./badge.service" -import { LeaderboardEntry } from "../leaderboard/entities/leaderboard.entity" -import { BadgeController } from "./badge.controller" +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Badge } from './entities/badge.entity'; +import { BadgeService } from './badge.service'; +import { LeaderboardEntry } from '../leaderboard/entities/leaderboard.entity'; +import { BadgeController } from './badge.controller'; +import { SeedDefaultBadgesService } from './providers/seed-default-badges.service'; +import { DetermineBadgeForRankService } from './providers/determine-badge-for-rank.service'; +import { AutoAssignBadgesService } from './providers/auto-assign-badges.service'; +import { GetBadgeByRankService } from './providers/get-badge-by-rank.service'; +import { RemoveBadgeService } from './providers/remove-badge.service'; +import { UpdateBadgeService } from './providers/update-badge.service'; +import { CreateBadgeService } from './providers/create-badge.service'; +import { FindOneBadgeService } from './providers/find-one-badge.service'; +import { FindAllActiveBadgesService } from './providers/find-all-active-badges.service'; +import { FindAllBadgesService } from './providers/find-all-badges.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([Badge, LeaderboardEntry]), - ], + imports: [TypeOrmModule.forFeature([Badge, LeaderboardEntry])], controllers: [BadgeController], - providers: [BadgeService], + providers: [ + BadgeService, + SeedDefaultBadgesService, + DetermineBadgeForRankService, + AutoAssignBadgesService, + GetBadgeByRankService, + RemoveBadgeService, + UpdateBadgeService, + CreateBadgeService, + FindOneBadgeService, + FindAllActiveBadgesService, + FindAllBadgesService, + ], exports: [BadgeService], }) export class BadgeModule {} diff --git a/src/badge/badge.service.spec.ts b/src/badge/badge.service.spec.ts index dde1bb6..90f7860 100644 --- a/src/badge/badge.service.spec.ts +++ b/src/badge/badge.service.spec.ts @@ -1,8 +1,26 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadgeService } from './badge.service'; -import { Badge } from './entities/badge.entity'; -import { LeaderboardEntry } from 'src/leaderboard/entities/leaderboard.entity'; -import { getRepositoryToken } from '@nestjs/typeorm'; +import { SeedDefaultBadgesService } from './providers/seed-default-badges.service'; +import { DetermineBadgeForRankService } from './providers/determine-badge-for-rank.service'; +import { AutoAssignBadgesService } from './providers/auto-assign-badges.service'; +import { GetBadgeByRankService } from './providers/get-badge-by-rank.service'; +import { RemoveBadgeService } from './providers/remove-badge.service'; +import { UpdateBadgeService } from './providers/update-badge.service'; +import { CreateBadgeService } from './providers/create-badge.service'; +import { FindOneBadgeService } from './providers/find-one-badge.service'; +import { FindAllActiveBadgesService } from './providers/find-all-active-badges.service'; +import { FindAllBadgesService } from './providers/find-all-badges.service'; + +const mockSeedDefaultBadgesService = { seedDefaultBadges: jest.fn() }; +const mockDetermineBadgeForRankService = { determineBadgeForRank: jest.fn() }; +const mockAutoAssignBadgesService = { autoAssignBadges: jest.fn() }; +const mockGetBadgeByRankService = { getBadgeByRank: jest.fn() }; +const mockRemoveBadgeService = { remove: jest.fn() }; +const mockUpdateBadgeService = { update: jest.fn() }; +const mockCreateBadgeService = { create: jest.fn() }; +const mockFindOneBadgeService = { findOne: jest.fn() }; +const mockFindAllActiveBadgesService = { findAllActive: jest.fn() }; +const mockFindAllBadgesService = { findAll: jest.fn() }; describe('BadgeService', () => { let service: BadgeService; @@ -12,28 +30,134 @@ describe('BadgeService', () => { providers: [ BadgeService, { - provide: getRepositoryToken(Badge), - useValue: { - find: jest.fn(), - findOne: jest.fn(), - save: jest.fn(), - }, + provide: SeedDefaultBadgesService, + useValue: mockSeedDefaultBadgesService, + }, + { + provide: DetermineBadgeForRankService, + useValue: mockDetermineBadgeForRankService, }, { - provide: getRepositoryToken(LeaderboardEntry), - useValue: { - find: jest.fn(), - findOne: jest.fn(), - save: jest.fn(), - }, + provide: AutoAssignBadgesService, + useValue: mockAutoAssignBadgesService, }, + { provide: GetBadgeByRankService, useValue: mockGetBadgeByRankService }, + { provide: RemoveBadgeService, useValue: mockRemoveBadgeService }, + { provide: UpdateBadgeService, useValue: mockUpdateBadgeService }, + { provide: CreateBadgeService, useValue: mockCreateBadgeService }, + { provide: FindOneBadgeService, useValue: mockFindOneBadgeService }, + { + provide: FindAllActiveBadgesService, + useValue: mockFindAllActiveBadgesService, + }, + { provide: FindAllBadgesService, useValue: mockFindAllBadgesService }, ], }).compile(); service = module.get(BadgeService); + jest.clearAllMocks(); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('findAll', () => { + it('should delegate to FindAllBadgesService', async () => { + const mockResult = [{ id: 1, title: 'Test Badge' }]; + mockFindAllBadgesService.findAll.mockResolvedValue(mockResult); + const result = await service.findAll(); + expect(mockFindAllBadgesService.findAll).toHaveBeenCalled(); + expect(result).toBe(mockResult); + }); + }); + + describe('findAllActive', () => { + it('should delegate to FindAllActiveBadgesService', async () => { + const mockResult = [{ id: 1, title: 'Active Badge', isActive: true }]; + mockFindAllActiveBadgesService.findAllActive.mockResolvedValue( + mockResult, + ); + const result = await service.findAllActive(); + expect(mockFindAllActiveBadgesService.findAllActive).toHaveBeenCalled(); + expect(result).toBe(mockResult); + }); + }); + + describe('findOne', () => { + it('should delegate to FindOneBadgeService', async () => { + const mockResult = { id: 1, title: 'Test Badge' }; + mockFindOneBadgeService.findOne.mockResolvedValue(mockResult); + const result = await service.findOne(1); + expect(mockFindOneBadgeService.findOne).toHaveBeenCalledWith(1); + expect(result).toBe(mockResult); + }); + }); + + describe('create', () => { + it('should delegate to CreateBadgeService', async () => { + const createDto = { + title: 'New Badge', + rank: 1, + description: 'Test description', + }; + const mockResult = { + id: 1, + title: 'New Badge', + rank: 1, + description: 'Test description', + }; + mockCreateBadgeService.create.mockResolvedValue(mockResult); + const result = await service.create(createDto); + expect(mockCreateBadgeService.create).toHaveBeenCalledWith(createDto); + expect(result).toBe(mockResult); + }); + }); + + describe('update', () => { + it('should delegate to UpdateBadgeService', async () => { + const updateDto = { title: 'Updated Badge' }; + const mockResult = { id: 1, title: 'Updated Badge' }; + mockUpdateBadgeService.update.mockResolvedValue(mockResult); + const result = await service.update(1, updateDto); + expect(mockUpdateBadgeService.update).toHaveBeenCalledWith(1, updateDto); + expect(result).toBe(mockResult); + }); + }); + + describe('remove', () => { + it('should delegate to RemoveBadgeService', async () => { + mockRemoveBadgeService.remove.mockResolvedValue(undefined); + await service.remove(1); + expect(mockRemoveBadgeService.remove).toHaveBeenCalledWith(1); + }); + }); + + describe('getBadgeByRank', () => { + it('should delegate to GetBadgeByRankService', async () => { + const mockResult = { id: 1, title: 'Rank Badge', rank: 1 }; + mockGetBadgeByRankService.getBadgeByRank.mockResolvedValue(mockResult); + const result = await service.getBadgeByRank(1); + expect(mockGetBadgeByRankService.getBadgeByRank).toHaveBeenCalledWith(1); + expect(result).toBe(mockResult); + }); + }); + + describe('autoAssignBadges', () => { + it('should delegate to AutoAssignBadgesService', async () => { + mockAutoAssignBadgesService.autoAssignBadges.mockResolvedValue(undefined); + await service.autoAssignBadges(); + expect(mockAutoAssignBadgesService.autoAssignBadges).toHaveBeenCalled(); + }); + }); + + describe('seedDefaultBadges', () => { + it('should delegate to SeedDefaultBadgesService', async () => { + mockSeedDefaultBadgesService.seedDefaultBadges.mockResolvedValue( + undefined, + ); + await service.seedDefaultBadges(); + expect(mockSeedDefaultBadgesService.seedDefaultBadges).toHaveBeenCalled(); + }); + }); }); diff --git a/src/badge/badge.service.ts b/src/badge/badge.service.ts index 0c3ff89..58d7dc8 100644 --- a/src/badge/badge.service.ts +++ b/src/badge/badge.service.ts @@ -1,229 +1,66 @@ -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; import { Badge } from './entities/badge.entity'; -import { LeaderboardEntry } from 'src/leaderboard/entities/leaderboard.entity'; -import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { CreateBadgeDto } from './dto/create-badge.dto'; import { UpdateBadgeDto } from './dto/update-badge.dto'; +import { SeedDefaultBadgesService } from './providers/seed-default-badges.service'; +import { DetermineBadgeForRankService } from './providers/determine-badge-for-rank.service'; +import { AutoAssignBadgesService } from './providers/auto-assign-badges.service'; +import { GetBadgeByRankService } from './providers/get-badge-by-rank.service'; +import { RemoveBadgeService } from './providers/remove-badge.service'; +import { UpdateBadgeService } from './providers/update-badge.service'; +import { CreateBadgeService } from './providers/create-badge.service'; +import { FindOneBadgeService } from './providers/find-one-badge.service'; +import { FindAllActiveBadgesService } from './providers/find-all-active-badges.service'; +import { FindAllBadgesService } from './providers/find-all-badges.service'; @Injectable() export class BadgeService { - private readonly logger = new Logger(BadgeService.name); - constructor( - @InjectRepository(Badge) - private readonly badgeRepository: Repository, - - @InjectRepository(LeaderboardEntry) - private readonly leaderboardRepository: Repository, + private readonly seedDefaultBadgesService: SeedDefaultBadgesService, + private readonly determineBadgeForRankService: DetermineBadgeForRankService, + private readonly autoAssignBadgesService: AutoAssignBadgesService, + private readonly getBadgeByRankService: GetBadgeByRankService, + private readonly removeBadgeService: RemoveBadgeService, + private readonly updateBadgeService: UpdateBadgeService, + private readonly createBadgeService: CreateBadgeService, + private readonly findOneBadgeService: FindOneBadgeService, + private readonly findAllActiveBadgesService: FindAllActiveBadgesService, + private readonly findAllBadgesService: FindAllBadgesService, ) {} async findAll(): Promise { - return this.badgeRepository.find({ - order: { rank: "ASC" }, - }) + return this.findAllBadgesService.findAll(); } async findAllActive(): Promise { - return this.badgeRepository.find({ - where: { isActive: true }, - order: { rank: "ASC" }, - }) + return this.findAllActiveBadgesService.findAllActive(); } async findOne(id: number): Promise { - const badge = await this.badgeRepository.findOne({ - where: { id }, - relations: ["leaderboardEntries"], - }) - - if (!badge) { - throw new NotFoundException(`Badge with ID ${id} not found`) - } - - return badge + return this.findOneBadgeService.findOne(id); } async create(createBadgeDto: CreateBadgeDto): Promise { - // Check if badge with same title already exists - const existingBadge = await this.badgeRepository.findOne({ - where: { title: createBadgeDto.title }, - }) - - if (existingBadge) { - throw new ConflictException(`Badge with title "${createBadgeDto.title}" already exists`) - } - - // Check if badge with same rank already exists - const existingRank = await this.badgeRepository.findOne({ - where: { rank: createBadgeDto.rank }, - }) - - if (existingRank) { - throw new ConflictException(`Badge with rank ${createBadgeDto.rank} already exists`) - } - - const badge = this.badgeRepository.create(createBadgeDto) - return this.badgeRepository.save(badge) + return this.createBadgeService.create(createBadgeDto); } async update(id: number, updateBadgeDto: UpdateBadgeDto): Promise { - const badge = await this.findOne(id) - - // Check for title conflicts if title is being updated - if (updateBadgeDto.title && updateBadgeDto.title !== badge.title) { - const existingBadge = await this.badgeRepository.findOne({ - where: { title: updateBadgeDto.title }, - }) - - if (existingBadge) { - throw new ConflictException(`Badge with title "${updateBadgeDto.title}" already exists`) - } - } - - // Check for rank conflicts if rank is being updated - if (updateBadgeDto.rank && updateBadgeDto.rank !== badge.rank) { - const existingRank = await this.badgeRepository.findOne({ - where: { rank: updateBadgeDto.rank }, - }) - - if (existingRank) { - throw new ConflictException(`Badge with rank ${updateBadgeDto.rank} already exists`) - } - } - - Object.assign(badge, updateBadgeDto) - return this.badgeRepository.save(badge) + return this.updateBadgeService.update(id, updateBadgeDto); } async remove(id: number): Promise { - const badge = await this.findOne(id) - - // Check if badge is assigned to any leaderboard entries - const assignedEntries = await this.leaderboardRepository.count({ - where: { badge: { id } }, - }) - - if (assignedEntries > 0) { - throw new ConflictException( - `Cannot delete badge "${badge.title}" as it is assigned to ${assignedEntries} leaderboard entries`, - ) - } - - await this.badgeRepository.remove(badge) + return this.removeBadgeService.remove(id); } async getBadgeByRank(rank: number): Promise { - return this.badgeRepository.findOne({ - where: { rank, isActive: true }, - }) + return this.getBadgeByRankService.getBadgeByRank(rank); } async autoAssignBadges(): Promise { - this.logger.log("Starting automatic badge assignment...") - - try { - // Get all leaderboard entries ordered by score - const leaderboardEntries = await this.leaderboardRepository.find({ - order: { score: "DESC" }, - relations: ["player"], - }) - - // Get all auto-assignable badges - const autoAssignableBadges = await this.badgeRepository.find({ - where: { isAutoAssigned: true, isActive: true }, - order: { rank: "ASC" }, - }) - - for (let i = 0; i < leaderboardEntries.length; i++) { - const entry = leaderboardEntries[i] - const playerRank = i + 1 // 1-based rank - const appropriateBadge = this.determineBadgeForRank(playerRank, autoAssignableBadges) - - if (appropriateBadge && (!entry.badge || entry.badge.id !== appropriateBadge.id)) { - entry.badge = appropriateBadge - await this.leaderboardRepository.save(entry) - this.logger.log(`Assigned badge "${appropriateBadge.title}" to player at rank ${playerRank}`) - } - } - - this.logger.log("Automatic badge assignment completed") - } catch (error) { - this.logger.error("Error during automatic badge assignment:", error) - throw error - } - } - - private determineBadgeForRank(playerRank: number, badges: Badge[]): Badge | null { - // Badge assignment logic based on rank - if (playerRank === 1) { - return badges.find((badge) => badge.title === "Puzzle Master") || null - } else if (playerRank === 2) { - return badges.find((badge) => badge.title === "Grand Champion") || null - } else if (playerRank === 3) { - return badges.find((badge) => badge.title === "Blockchain Expert") || null - } else if (playerRank >= 4 && playerRank <= 10) { - return badges.find((badge) => badge.title === "Algorithm Specialist") || null - } else if (playerRank > 10) { - return badges.find((badge) => badge.title === "Rising Star") || null - } - - return null + return this.autoAssignBadgesService.autoAssignBadges(); } async seedDefaultBadges(): Promise { - this.logger.log("Seeding default badges...") - - const defaultBadges = [ - { - title: "Puzzle Master", - description: "Awarded to the top performer on the leaderboard", - rank: 1, - isAutoAssigned: true, - iconUrl: "/badges/puzzle-master.png", - }, - { - title: "Grand Champion", - description: "Awarded to the second-place performer", - rank: 2, - isAutoAssigned: true, - iconUrl: "/badges/grand-champion.png", - }, - { - title: "Blockchain Expert", - description: "Awarded to the third-place performer", - rank: 3, - isAutoAssigned: true, - iconUrl: "/badges/blockchain-expert.png", - }, - { - title: "Algorithm Specialist", - description: "Awarded to top 10 performers", - rank: 4, - isAutoAssigned: true, - iconUrl: "/badges/algorithm-specialist.png", - }, - { - title: "Rising Star", - description: "Awarded to promising performers", - rank: 5, - isAutoAssigned: true, - iconUrl: "/badges/rising-star.png", - }, - ] - - for (const badgeData of defaultBadges) { - const existingBadge = await this.badgeRepository.findOne({ - where: { title: badgeData.title }, - }) - - if (!existingBadge) { - const badge = this.badgeRepository.create(badgeData) - await this.badgeRepository.save(badge) - this.logger.log(`Created default badge: ${badgeData.title}`) - } - } - - this.logger.log("Default badges seeding completed") + return this.seedDefaultBadgesService.seedDefaultBadges(); } } diff --git a/src/badge/providers/auto-assign-badges.service.ts b/src/badge/providers/auto-assign-badges.service.ts new file mode 100644 index 0000000..ebe944d --- /dev/null +++ b/src/badge/providers/auto-assign-badges.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; +import { LeaderboardEntry } from '../../leaderboard/entities/leaderboard.entity'; +import { DetermineBadgeForRankService } from './determine-badge-for-rank.service'; + +@Injectable() +export class AutoAssignBadgesService { + private readonly logger = new Logger(AutoAssignBadgesService.name); + + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + @InjectRepository(LeaderboardEntry) + private readonly leaderboardRepository: Repository, + private readonly determineBadgeForRankService: DetermineBadgeForRankService, + ) {} + + async autoAssignBadges(): Promise { + this.logger.log('Starting automatic badge assignment...'); + + try { + // Get all leaderboard entries ordered by score + const leaderboardEntries = await this.leaderboardRepository.find({ + order: { score: 'DESC' }, + relations: ['player'], + }); + + // Get all auto-assignable badges + const autoAssignableBadges = await this.badgeRepository.find({ + where: { isAutoAssigned: true, isActive: true }, + order: { rank: 'ASC' }, + }); + + for (let i = 0; i < leaderboardEntries.length; i++) { + const entry = leaderboardEntries[i]; + const playerRank = i + 1; // 1-based rank + const appropriateBadge = + this.determineBadgeForRankService.determineBadgeForRank( + playerRank, + autoAssignableBadges, + ); + + if ( + appropriateBadge && + (!entry.badge || entry.badge.id !== appropriateBadge.id) + ) { + entry.badge = appropriateBadge; + await this.leaderboardRepository.save(entry); + this.logger.log( + `Assigned badge "${appropriateBadge.title}" to player at rank ${playerRank}`, + ); + } + } + + this.logger.log('Automatic badge assignment completed'); + } catch (error) { + this.logger.error('Error during automatic badge assignment:', error); + throw error; + } + } +} diff --git a/src/badge/providers/create-badge.service.ts b/src/badge/providers/create-badge.service.ts new file mode 100644 index 0000000..b7d18e8 --- /dev/null +++ b/src/badge/providers/create-badge.service.ts @@ -0,0 +1,40 @@ +import { Injectable, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; +import { CreateBadgeDto } from '../dto/create-badge.dto'; + +@Injectable() +export class CreateBadgeService { + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + ) {} + + async create(createBadgeDto: CreateBadgeDto): Promise { + // Check if badge with same title already exists + const existingBadge = await this.badgeRepository.findOne({ + where: { title: createBadgeDto.title }, + }); + + if (existingBadge) { + throw new ConflictException( + `Badge with title "${createBadgeDto.title}" already exists`, + ); + } + + // Check if badge with same rank already exists + const existingRank = await this.badgeRepository.findOne({ + where: { rank: createBadgeDto.rank }, + }); + + if (existingRank) { + throw new ConflictException( + `Badge with rank ${createBadgeDto.rank} already exists`, + ); + } + + const badge = this.badgeRepository.create(createBadgeDto); + return this.badgeRepository.save(badge); + } +} diff --git a/src/badge/providers/determine-badge-for-rank.service.ts b/src/badge/providers/determine-badge-for-rank.service.ts new file mode 100644 index 0000000..5ba6f89 --- /dev/null +++ b/src/badge/providers/determine-badge-for-rank.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { Badge } from '../entities/badge.entity'; + +@Injectable() +export class DetermineBadgeForRankService { + determineBadgeForRank(playerRank: number, badges: Badge[]): Badge | null { + // Badge assignment logic based on rank + if (playerRank === 1) { + return badges.find((badge) => badge.title === "Puzzle Master") || null; + } else if (playerRank === 2) { + return badges.find((badge) => badge.title === "Grand Champion") || null; + } else if (playerRank === 3) { + return badges.find((badge) => badge.title === "Blockchain Expert") || null; + } else if (playerRank >= 4 && playerRank <= 10) { + return badges.find((badge) => badge.title === "Algorithm Specialist") || null; + } else if (playerRank > 10) { + return badges.find((badge) => badge.title === "Rising Star") || null; + } + + return null; + } +} \ No newline at end of file diff --git a/src/badge/providers/find-all-active-badges.service.ts b/src/badge/providers/find-all-active-badges.service.ts new file mode 100644 index 0000000..ab47597 --- /dev/null +++ b/src/badge/providers/find-all-active-badges.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; + +@Injectable() +export class FindAllActiveBadgesService { + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + ) {} + + async findAllActive(): Promise { + return this.badgeRepository.find({ + where: { isActive: true }, + order: { rank: "ASC" }, + }); + } +} \ No newline at end of file diff --git a/src/badge/providers/find-all-badges.service.ts b/src/badge/providers/find-all-badges.service.ts new file mode 100644 index 0000000..68d4ed5 --- /dev/null +++ b/src/badge/providers/find-all-badges.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; + +@Injectable() +export class FindAllBadgesService { + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + ) {} + + async findAll(): Promise { + return this.badgeRepository.find({ + order: { rank: "ASC" }, + }); + } +} \ No newline at end of file diff --git a/src/badge/providers/find-one-badge.service.ts b/src/badge/providers/find-one-badge.service.ts new file mode 100644 index 0000000..9e0ae0f --- /dev/null +++ b/src/badge/providers/find-one-badge.service.ts @@ -0,0 +1,25 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; + +@Injectable() +export class FindOneBadgeService { + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + ) {} + + async findOne(id: number): Promise { + const badge = await this.badgeRepository.findOne({ + where: { id }, + relations: ["leaderboardEntries"], + }); + + if (!badge) { + throw new NotFoundException(`Badge with ID ${id} not found`); + } + + return badge; + } +} \ No newline at end of file diff --git a/src/badge/providers/get-badge-by-rank.service.ts b/src/badge/providers/get-badge-by-rank.service.ts new file mode 100644 index 0000000..ec93ca0 --- /dev/null +++ b/src/badge/providers/get-badge-by-rank.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; + +@Injectable() +export class GetBadgeByRankService { + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + ) {} + + async getBadgeByRank(rank: number): Promise { + return this.badgeRepository.findOne({ + where: { rank, isActive: true }, + }); + } +} \ No newline at end of file diff --git a/src/badge/providers/remove-badge.service.ts b/src/badge/providers/remove-badge.service.ts new file mode 100644 index 0000000..e847c29 --- /dev/null +++ b/src/badge/providers/remove-badge.service.ts @@ -0,0 +1,38 @@ +import { + Injectable, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; +import { LeaderboardEntry } from '../../leaderboard/entities/leaderboard.entity'; +import { FindOneBadgeService } from './find-one-badge.service'; + +@Injectable() +export class RemoveBadgeService { + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + @InjectRepository(LeaderboardEntry) + private readonly leaderboardRepository: Repository, + private readonly findOneBadgeService: FindOneBadgeService, + ) {} + + async remove(id: number): Promise { + const badge = await this.findOneBadgeService.findOne(id); + + // Check if badge is assigned to any leaderboard entries + const assignedEntries = await this.leaderboardRepository.count({ + where: { badge: { id } }, + }); + + if (assignedEntries > 0) { + throw new ConflictException( + `Cannot delete badge "${badge.title}" as it is assigned to ${assignedEntries} leaderboard entries`, + ); + } + + await this.badgeRepository.remove(badge); + } +} diff --git a/src/badge/providers/seed-default-badges.service.ts b/src/badge/providers/seed-default-badges.service.ts new file mode 100644 index 0000000..62f08ea --- /dev/null +++ b/src/badge/providers/seed-default-badges.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; + +@Injectable() +export class SeedDefaultBadgesService { + private readonly logger = new Logger(SeedDefaultBadgesService.name); + + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + ) {} + + async seedDefaultBadges(): Promise { + this.logger.log('Seeding default badges...'); + + const defaultBadges = [ + { + title: 'Puzzle Master', + description: 'Awarded to the top performer on the leaderboard', + rank: 1, + isAutoAssigned: true, + iconUrl: '/badges/puzzle-master.png', + }, + { + title: 'Grand Champion', + description: 'Awarded to the second-place performer', + rank: 2, + isAutoAssigned: true, + iconUrl: '/badges/grand-champion.png', + }, + { + title: 'Blockchain Expert', + description: 'Awarded to the third-place performer', + rank: 3, + isAutoAssigned: true, + iconUrl: '/badges/blockchain-expert.png', + }, + { + title: 'Algorithm Specialist', + description: 'Awarded to top 10 performers', + rank: 4, + isAutoAssigned: true, + iconUrl: '/badges/algorithm-specialist.png', + }, + { + title: 'Rising Star', + description: 'Awarded to promising performers', + rank: 5, + isAutoAssigned: true, + iconUrl: '/badges/rising-star.png', + }, + ]; + + for (const badgeData of defaultBadges) { + const existingBadge = await this.badgeRepository.findOne({ + where: { title: badgeData.title }, + }); + + if (!existingBadge) { + const badge = this.badgeRepository.create(badgeData); + await this.badgeRepository.save(badge); + this.logger.log(`Created default badge: ${badgeData.title}`); + } + } + + this.logger.log('Default badges seeding completed'); + } +} diff --git a/src/badge/providers/update-badge.service.ts b/src/badge/providers/update-badge.service.ts new file mode 100644 index 0000000..3df4080 --- /dev/null +++ b/src/badge/providers/update-badge.service.ts @@ -0,0 +1,44 @@ +import { Injectable, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Badge } from '../entities/badge.entity'; +import { UpdateBadgeDto } from '../dto/update-badge.dto'; +import { FindOneBadgeService } from './find-one-badge.service'; + +@Injectable() +export class UpdateBadgeService { + constructor( + @InjectRepository(Badge) + private readonly badgeRepository: Repository, + private readonly findOneBadgeService: FindOneBadgeService, + ) {} + + async update(id: number, updateBadgeDto: UpdateBadgeDto): Promise { + const badge = await this.findOneBadgeService.findOne(id); + + // Check for title conflicts if title is being updated + if (updateBadgeDto.title && updateBadgeDto.title !== badge.title) { + const existingBadge = await this.badgeRepository.findOne({ + where: { title: updateBadgeDto.title }, + }); + + if (existingBadge) { + throw new ConflictException(`Badge with title "${updateBadgeDto.title}" already exists`); + } + } + + // Check for rank conflicts if rank is being updated + if (updateBadgeDto.rank && updateBadgeDto.rank !== badge.rank) { + const existingRank = await this.badgeRepository.findOne({ + where: { rank: updateBadgeDto.rank }, + }); + + if (existingRank) { + throw new ConflictException(`Badge with rank ${updateBadgeDto.rank} already exists`); + } + } + + Object.assign(badge, updateBadgeDto); + return this.badgeRepository.save(badge); + } +} \ No newline at end of file From cdb7bf0ca466f3478b9d3d1afc11f84e5cfcb292 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Wed, 16 Jul 2025 11:48:23 +0100 Subject: [PATCH 3/3] fixed puzzle service spec error --- src/puzzle/puzzle.service.spec.ts | 47 +++++++++++++------------------ 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/src/puzzle/puzzle.service.spec.ts b/src/puzzle/puzzle.service.spec.ts index 6348437..2cb5099 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,18 +74,6 @@ 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(); @@ -147,7 +151,7 @@ describe('PuzzleService', () => { userId, puzzleId, submitDto, - ); + }); expect(result.success).toBe(true); expect(result.xpEarned).toBe(100); @@ -163,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); @@ -182,18 +186,7 @@ describe('PuzzleService', () => { const result = await service.submitPuzzleSolution( userId, puzzleId, - submitDto, - ); - - attemptData: { solution: submitDtoIncorrect.solution }, - result: false, - submittedAt: new Date(), - }); - - const result = await service.submitPuzzleSolution( - userId, - puzzleId, - submitDtoIncorrect, + incorrectDto, ); expect(mockEventEmitter.emit).toHaveBeenCalledWith('puzzle.submitted', { @@ -309,8 +302,6 @@ 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 = [ {