From 4452ec8ff4ce44b433a60a0ec97b3f1c72aef57c Mon Sep 17 00:00:00 2001 From: Ibinola Date: Thu, 10 Jul 2025 16:29:28 +0100 Subject: [PATCH 1/2] feat: implement puzzle progress and added tests --- src/app.module.ts | 4 +- .../dto/create-puzzle-progress.dto.ts | 1 + .../dto/update-puzzle-progress.dto.ts | 4 + .../entities/puzzle-progress.entity.ts | 1 + .../puzzle-progress.controller.spec.ts | 20 +++ .../puzzle-progress.controller.ts | 30 ++++ src/puzzle-progress/puzzle-progress.module.ts | 10 ++ .../puzzle-progress.service.spec.ts | 18 ++ .../puzzle-progress.service.ts | 156 ++++++++++++++++++ src/puzzle/entities/puzzle.entity.ts | 23 ++- src/puzzle/enums/puzzle-type.enum.ts | 6 +- 11 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 src/puzzle-progress/dto/create-puzzle-progress.dto.ts create mode 100644 src/puzzle-progress/dto/update-puzzle-progress.dto.ts create mode 100644 src/puzzle-progress/entities/puzzle-progress.entity.ts create mode 100644 src/puzzle-progress/puzzle-progress.controller.spec.ts create mode 100644 src/puzzle-progress/puzzle-progress.controller.ts create mode 100644 src/puzzle-progress/puzzle-progress.module.ts create mode 100644 src/puzzle-progress/puzzle-progress.service.spec.ts create mode 100644 src/puzzle-progress/puzzle-progress.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 4e0f253..944bd2d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { AppService } from './app.service'; import { AppController } from './app.controller'; import { GamificationModule } from './gamification/gamification.module'; import { AchievementModule } from './achievement/achievement.module'; +import { PuzzleProgressModule } from './puzzle-progress/puzzle-progress.module'; // const ENV = process.env.NODE_ENV; // console.log('NODE_ENV:', process.env.NODE_ENV); @@ -56,7 +57,8 @@ import { AchievementModule } from './achievement/achievement.module'; IQAssessmentModule, PuzzleModule, GamificationModule, - AchievementModule + AchievementModule, + PuzzleProgressModule ], controllers: [AppController], providers: [AppService], diff --git a/src/puzzle-progress/dto/create-puzzle-progress.dto.ts b/src/puzzle-progress/dto/create-puzzle-progress.dto.ts new file mode 100644 index 0000000..26b2e41 --- /dev/null +++ b/src/puzzle-progress/dto/create-puzzle-progress.dto.ts @@ -0,0 +1 @@ +export class CreatePuzzleProgressDto {} diff --git a/src/puzzle-progress/dto/update-puzzle-progress.dto.ts b/src/puzzle-progress/dto/update-puzzle-progress.dto.ts new file mode 100644 index 0000000..ba25afe --- /dev/null +++ b/src/puzzle-progress/dto/update-puzzle-progress.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePuzzleProgressDto } from './create-puzzle-progress.dto'; + +export class UpdatePuzzleProgressDto extends PartialType(CreatePuzzleProgressDto) {} diff --git a/src/puzzle-progress/entities/puzzle-progress.entity.ts b/src/puzzle-progress/entities/puzzle-progress.entity.ts new file mode 100644 index 0000000..4a90050 --- /dev/null +++ b/src/puzzle-progress/entities/puzzle-progress.entity.ts @@ -0,0 +1 @@ +export class PuzzleProgress {} diff --git a/src/puzzle-progress/puzzle-progress.controller.spec.ts b/src/puzzle-progress/puzzle-progress.controller.spec.ts new file mode 100644 index 0000000..e081c6b --- /dev/null +++ b/src/puzzle-progress/puzzle-progress.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PuzzleProgressController } from './puzzle-progress.controller'; +import { PuzzleProgressService } from './puzzle-progress.service'; + +describe('PuzzleProgressController', () => { + let controller: PuzzleProgressController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PuzzleProgressController], + providers: [PuzzleProgressService], + }).compile(); + + controller = module.get(PuzzleProgressController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/puzzle-progress/puzzle-progress.controller.ts b/src/puzzle-progress/puzzle-progress.controller.ts new file mode 100644 index 0000000..96cc491 --- /dev/null +++ b/src/puzzle-progress/puzzle-progress.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Post, Param, Body, HttpCode, HttpStatus, Logger } from '@nestjs/common'; +import { PuzzleProgressService } => './puzzle-progress.service'; +import { IsString, IsNotEmpty } from 'class-validator'; + +class SolvePuzzleDto { + @IsNotEmpty() + @IsString() + puzzleId: string; +} + +@Controller('users') +export class PuzzleProgressController { + private readonly logger = new Logger(PuzzleProgressController.name); + + constructor(private readonly puzzleProgressService: PuzzleProgressService) {} + + @Get(':id/progress') + @HttpCode(HttpStatus.OK) + getPuzzleProgress(@Param('id') userId: string): { [key: string]: { completed: number; total: number } } { + this.logger.log(`Received request for puzzle progress for user: ${userId}`); + return this.puzzleProgressService.getPuzzleProgress(userId); + } + + @Post(':id/solve-puzzle') + @HttpCode(HttpStatus.NO_CONTENT) + recordPuzzleSolve(@Param('id') userId: string, @Body() solvePuzzleDto: SolvePuzzleDto): void { + this.logger.log(`Received request to record puzzle solve for user: ${userId}, puzzle: ${solvePuzzleDto.puzzleId}`); + this.puzzleProgressService.recordPuzzleSolve(userId, solvePuzzleDto.puzzleId); + } +} \ No newline at end of file diff --git a/src/puzzle-progress/puzzle-progress.module.ts b/src/puzzle-progress/puzzle-progress.module.ts new file mode 100644 index 0000000..74f373c --- /dev/null +++ b/src/puzzle-progress/puzzle-progress.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PuzzleProgressService } from './puzzle-progress.service'; +import { PuzzleProgressController } from './puzzle-progress.controller'; + +@Module({ + providers: [PuzzleProgressService], + controllers: [PuzzleProgressController], + exports: [PuzzleProgressService], +}) +export class PuzzleProgressModule {} \ No newline at end of file diff --git a/src/puzzle-progress/puzzle-progress.service.spec.ts b/src/puzzle-progress/puzzle-progress.service.spec.ts new file mode 100644 index 0000000..a253159 --- /dev/null +++ b/src/puzzle-progress/puzzle-progress.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PuzzleProgressService } from './puzzle-progress.service'; + +describe('PuzzleProgressService', () => { + let service: PuzzleProgressService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PuzzleProgressService], + }).compile(); + + service = module.get(PuzzleProgressService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/puzzle-progress/puzzle-progress.service.ts b/src/puzzle-progress/puzzle-progress.service.ts new file mode 100644 index 0000000..0d66efa --- /dev/null +++ b/src/puzzle-progress/puzzle-progress.service.ts @@ -0,0 +1,156 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Puzzle, PuzzleCategory, PuzzleType, PuzzleDifficulty } from './puzzle.entity'; // Import new enums +import { v4 as uuidv4 } from 'uuid'; + +interface UserCategoryProgress { + completed: number; +} + +type UserProgressMap = Map; + +@Injectable() +export class PuzzleProgressService { + private readonly logger = new Logger(PuzzleProgressService.name); + + private puzzles: Puzzle[] = []; + private userProgress: Map = new Map(); + + constructor() { + this.seedPuzzles(); + } + + private seedPuzzles(): void { + this.logger.log('Seeding mock puzzles...'); + + const mockPuzzles: Omit[] = [ + { + title: 'Easy Sudoku', + description: 'A classic 9x9 sudoku puzzle.', + type: PuzzleType.LOGIC, + difficulty: PuzzleDifficulty.EASY, + solution: '...', + isPublished: true, + category: PuzzleCategory.LOGIC, + }, + { + title: 'Hard Sudoku', + description: 'A challenging 9x9 sudoku puzzle.', + type: PuzzleType.LOGIC, + difficulty: PuzzleDifficulty.HARD, + solution: '...', + isPublished: true, + category: PuzzleCategory.LOGIC, + }, + { + title: 'FizzBuzz Challenge', + description: 'Implement FizzBuzz in your favorite language.', + type: PuzzleType.CODING, + difficulty: PuzzleDifficulty.EASY, + solution: '...', + isPublished: true, + category: PuzzleCategory.CODING, + }, + { + title: 'Blockchain Basics Quiz', + description: 'Test your knowledge on fundamental blockchain concepts.', + type: PuzzleType.TRIVIA, + difficulty: PuzzleDifficulty.MEDIUM, + solution: '...', + isPublished: true, + category: PuzzleCategory.BLOCKCHAIN, + }, + { + title: 'NFT Minting Exercise', + description: 'Simulate minting an NFT on a testnet.', + type: PuzzleType.CODING, + difficulty: PuzzleDifficulty.HARD, + solution: '...', + isPublished: true, + category: PuzzleCategory.BLOCKCHAIN, + }, + { + title: 'Inactive Logic Puzzle', + description: 'This puzzle is not yet published.', + type: PuzzleType.LOGIC, + difficulty: PuzzleDifficulty.MEDIUM, + solution: '...', + isPublished: false, + category: PuzzleCategory.LOGIC, + }, + { + title: 'Math Series Problem', + description: 'Find the next number in the sequence.', + type: PuzzleType.MATH, + difficulty: PuzzleDifficulty.EASY, + solution: '...', + isPublished: true, + category: PuzzleCategory.MATH, + }, + { + title: 'General Trivia Round 1', + description: 'A mix of general knowledge questions.', + type: PuzzleType.TRIVIA, + difficulty: PuzzleDifficulty.EASY, + solution: '...', + isPublished: true, + category: PuzzleCategory.GENERAL, + }, + ]; + + this.puzzles = mockPuzzles.map(p => ({ ...p, id: uuidv4() })); + this.logger.log(`Seeded ${this.puzzles.length} puzzles.`); + } + + recordPuzzleSolve(userId: string, puzzleId: string): void { + this.logger.log(`Recording solve for user ${userId}, puzzle ${puzzleId}`); + + + const puzzle = this.puzzles.find(p => p.id === puzzleId && p.isPublished); + if (!puzzle) { + this.logger.warn(`Puzzle ${puzzleId} not found or not published.`); + throw new NotFoundException(`Puzzle with ID "${puzzleId}" not found or is not published.`); + } + + if (!this.userProgress.has(userId)) { + this.userProgress.set(userId, new Map()); + } + + const userCategoryProgress = this.userProgress.get(userId); + const currentCompleted = userCategoryProgress.get(puzzle.category)?.completed || 0; + userCategoryProgress.set(puzzle.category, { completed: currentCompleted + 1 }); + + this.logger.log(`User ${userId} completed puzzle ${puzzleId} in category ${puzzle.category}. New count: ${currentCompleted + 1}`); + } + + getPuzzleProgress(userId: string): { [key in PuzzleCategory]?: { completed: number; total: number } } { + this.logger.log(`Fetching puzzle progress for user ${userId}`); + + const progressBreakdown: { [key in PuzzleCategory]?: { completed: number; total: number } } = {}; + + + Object.values(PuzzleCategory).forEach(category => { + const total = this.puzzles.filter(p => p.category === category && p.isPublished).length; + progressBreakdown[category] = { completed: 0, total: total }; + }); + + + const userCategoryProgress = this.userProgress.get(userId); + if (userCategoryProgress) { + userCategoryProgress.forEach((data, category) => { + if (progressBreakdown[category]) { + progressBreakdown[category].completed = data.completed; + } else { + + progressBreakdown[category] = { completed: data.completed, total: 0 }; + } + }); + } + + return progressBreakdown; + } + + + getAllPuzzles(): Puzzle[] { + return this.puzzles; + } +} diff --git a/src/puzzle/entities/puzzle.entity.ts b/src/puzzle/entities/puzzle.entity.ts index 11e68de..1770690 100644 --- a/src/puzzle/entities/puzzle.entity.ts +++ b/src/puzzle/entities/puzzle.entity.ts @@ -3,12 +3,33 @@ import { ApiProperty } from '@nestjs/swagger'; import { PuzzleType } from '../enums/puzzle-type.enum'; import { PuzzleDifficulty } from '../enums/puzzle-difficulty.enum'; +export enum PuzzleCategory { + LOGIC = 'logic', + CODING = 'coding', + BLOCKCHAIN = 'blockchain', + MATH = 'math', + GENERAL = 'general', +} + + + +export interface Puzzle { + id: string; + title: string; + description: string; + type: PuzzleType; + difficulty: PuzzleDifficulty; + solution: string; + isPublished: boolean; + category: PuzzleCategory; // Added category field +} + @Entity() export class Puzzle { @PrimaryGeneratedColumn() @ApiProperty({ description: 'Unique identifier for the puzzle' }) - id: number; + id: string; @Column() @ApiProperty({ description: 'Title of the puzzle' }) diff --git a/src/puzzle/enums/puzzle-type.enum.ts b/src/puzzle/enums/puzzle-type.enum.ts index 81d632d..2b8ff5e 100644 --- a/src/puzzle/enums/puzzle-type.enum.ts +++ b/src/puzzle/enums/puzzle-type.enum.ts @@ -1,6 +1,8 @@ export enum PuzzleType { - LOGIC = 'logic', - CODING = 'coding', BLOCKCHAIN = 'blockchain', + TRIVIA = 'trivia', + RIDDLE = 'riddle', + CODING = 'coding', + LOGIC = 'logic', MATH = 'math', } From 724bbfb1ce7809630aef756af48afe09bed0cd22 Mon Sep 17 00:00:00 2001 From: Ibinola Date: Thu, 10 Jul 2025 16:29:36 +0100 Subject: [PATCH 2/2] feat: implement puzzle progress and added tests --- .../puzzle-progress.service.spec.ts | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/puzzle-progress/puzzle-progress.service.spec.ts b/src/puzzle-progress/puzzle-progress.service.spec.ts index a253159..5d1e4af 100644 --- a/src/puzzle-progress/puzzle-progress.service.spec.ts +++ b/src/puzzle-progress/puzzle-progress.service.spec.ts @@ -1,8 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { PuzzleProgressService } from './puzzle-progress.service'; +import { PuzzleCategory, Puzzle } from './puzzle.entity'; +import { NotFoundException } from '@nestjs/common'; -describe('PuzzleProgressService', () => { +describe('PuzzleProgressService (Unit Tests)', () => { let service: PuzzleProgressService; + let mockPuzzles: Puzzle[]; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -10,9 +12,90 @@ describe('PuzzleProgressService', () => { }).compile(); service = module.get(PuzzleProgressService); + mockPuzzles = service.getAllPuzzles(); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('recordPuzzleSolve', () => { + it('should record a puzzle solve for a new user and category', () => { + const userId = 'user1'; + const puzzleId = mockPuzzles.find(p => p.category === PuzzleCategory.LOGIC && p.isPublished).id; + service.recordPuzzleSolve(userId, puzzleId); + + const progress = service.getPuzzleProgress(userId); + expect(progress[PuzzleCategory.LOGIC].completed).toBe(1); + }); + + it('should increment completed count for an existing user and category', () => { + const userId = 'user2'; + const puzzleId1 = mockPuzzles.filter(p => p.category === PuzzleCategory.CODING && p.isPublished)[0].id; + const puzzleId2 = mockPuzzles.filter(p => p.category === PuzzleCategory.CODING && p.isPublished)[1].id; + + service.recordPuzzleSolve(userId, puzzleId1); + service.recordPuzzleSolve(userId, puzzleId2); + + const progress = service.getPuzzleProgress(userId); + expect(progress[PuzzleCategory.CODING].completed).toBe(2); + }); + + it('should throw NotFoundException if puzzle is not found', () => { + const userId = 'user3'; + const nonExistentPuzzleId = 'non-existent-puzzle'; + expect(() => service.recordPuzzleSolve(userId, nonExistentPuzzleId)).toThrow(NotFoundException); + expect(() => service.recordPuzzleSolve(userId, nonExistentPuzzleId)).toThrow('Puzzle with ID "non-existent-puzzle" not found or is not published.'); + }); + + it('should throw NotFoundException if puzzle is not published', () => { + const userId = 'user4'; + const notPublishedPuzzleId = mockPuzzles.find(p => p.isPublished === false).id; + expect(() => service.recordPuzzleSolve(userId, notPublishedPuzzleId)).toThrow(NotFoundException); + expect(() => service.recordPuzzleSolve(userId, notPublishedPuzzleId)).toThrow(`Puzzle with ID "${notPublishedPuzzleId}" not found or is not published.`); + }); + }); + + describe('getPuzzleProgress', () => { + it('should return initial progress for a user with no completed puzzles', () => { + const userId = 'newUser'; + const progress = service.getPuzzleProgress(userId); + + Object.values(PuzzleCategory).forEach(category => { + const totalPublishedPuzzlesInCategory = mockPuzzles.filter(p => p.category === category && p.isPublished).length; + expect(progress[category].completed).toBe(0); + expect(progress[category].total).toBe(totalPublishedPuzzlesInCategory); + }); + }); + + it('should return correct progress for a user with some completed puzzles', () => { + const userId = 'userWithProgress'; + const logicPuzzleId = mockPuzzles.find(p => p.category === PuzzleCategory.LOGIC && p.isPublished).id; + const codingPuzzleId = mockPuzzles.find(p => p.category === PuzzleCategory.CODING && p.isPublished).id; + + service.recordPuzzleSolve(userId, logicPuzzleId); + service.recordPuzzleSolve(userId, codingPuzzleId); + service.recordPuzzleSolve(userId, codingPuzzleId); + + const progress = service.getPuzzleProgress(userId); + + expect(progress[PuzzleCategory.LOGIC].completed).toBe(1); + expect(progress[PuzzleCategory.CODING].completed).toBe(2); + expect(progress[PuzzleCategory.BLOCKCHAIN].completed).toBe(0); + expect(progress[PuzzleCategory.MATH].completed).toBe(0); + expect(progress[PuzzleCategory.GENERAL].completed).toBe(0); + + expect(progress[PuzzleCategory.LOGIC].total).toBe(mockPuzzles.filter(p => p.category === PuzzleCategory.LOGIC && p.isPublished).length); + expect(progress[PuzzleCategory.CODING].total).toBe(mockPuzzles.filter(p => p.category === PuzzleCategory.CODING && p.isPublished).length); + }); + + it('should handle cases where a category has no published puzzles', () => { + const userId = 'userEmptyCategory'; + + const progress = service.getPuzzleProgress(userId); + + }); + }); }); + +