diff --git a/backend/src/categories/providers/categories.service.spec.ts b/backend/src/categories/providers/categories.service.spec.ts new file mode 100644 index 00000000..47edbe94 --- /dev/null +++ b/backend/src/categories/providers/categories.service.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { + ConflictException, + InternalServerErrorException, +} from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { CategoriesService } from './categories.service'; +import { Category } from '../entities/category.entity'; +import { CreateCategoryDto } from '../dtos/create-category.dto'; + +function makeCategory(overrides?: Partial): Category { + return { + id: 'cat-1', + name: 'Logic', + description: 'Logic puzzles', + isActive: true, + createdAt: new Date(), + puzzles: [], + ...overrides, + }; +} + +describe('CategoriesService', () => { + let service: CategoriesService; + let categoryRepo: jest.Mocked>; + + beforeEach(async () => { + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CategoriesService, + { provide: getRepositoryToken(Category), useValue: mockRepo }, + ], + }).compile(); + + service = module.get(CategoriesService); + categoryRepo = module.get(getRepositoryToken(Category)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('lists categories ordered by name ASC', async () => { + const rows = [makeCategory()]; + categoryRepo.find.mockResolvedValue(rows); + + const result = await service.findAll(); + + expect(categoryRepo.find).toHaveBeenCalledWith({ order: { name: 'ASC' } }); + expect(result).toBe(rows); + }); + + it('finds a category by id and returns null when missing', async () => { + categoryRepo.findOne.mockResolvedValueOnce(makeCategory()); + await expect(service.findOne('cat-1')).resolves.toMatchObject({ + id: 'cat-1', + }); + + categoryRepo.findOne.mockResolvedValueOnce(null); + await expect(service.findOne('missing')).resolves.toBeNull(); + }); + + it('returns only active categories', async () => { + const active = [makeCategory()]; + categoryRepo.find.mockResolvedValue(active); + + await expect(service.findActive()).resolves.toBe(active); + expect(categoryRepo.find).toHaveBeenCalledWith({ + where: { isActive: true }, + }); + }); + + describe('create', () => { + const dto: CreateCategoryDto = { name: 'Logic', description: 'puzzles' }; + + it('creates and saves a new category', async () => { + const created = makeCategory(); + categoryRepo.findOne.mockResolvedValue(null); + categoryRepo.create.mockReturnValue(created); + categoryRepo.save.mockResolvedValue(created); + + const result = await service.create(dto); + + expect(categoryRepo.findOne).toHaveBeenCalledWith({ + where: { name: 'Logic' }, + }); + expect(categoryRepo.create).toHaveBeenCalledWith(dto); + expect(result).toBe(created); + }); + + it('throws ConflictException when the name already exists', async () => { + categoryRepo.findOne.mockResolvedValue(makeCategory()); + + await expect(service.create(dto)).rejects.toThrow(ConflictException); + expect(categoryRepo.create).not.toHaveBeenCalled(); + }); + + it('maps a unique-constraint race (Postgres 23505) to ConflictException', async () => { + categoryRepo.findOne.mockResolvedValue(null); + categoryRepo.create.mockReturnValue(makeCategory()); + const uniqueError = Object.assign(new Error('duplicate'), { + code: '23505', + }); + categoryRepo.save.mockRejectedValue(uniqueError); + + await expect(service.create(dto)).rejects.toThrow(ConflictException); + }); + + it('throws InternalServerErrorException for other save failures', async () => { + categoryRepo.findOne.mockResolvedValue(null); + categoryRepo.create.mockReturnValue(makeCategory()); + categoryRepo.save.mockRejectedValue(new Error('disk full')); + + await expect(service.create(dto)).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); + + it('updates a category and returns the refreshed row', async () => { + const updated = makeCategory({ name: 'Reasoning' }); + categoryRepo.update.mockResolvedValue({} as any); + categoryRepo.findOne.mockResolvedValue(updated); + + const result = await service.update('cat-1', { name: 'Reasoning' }); + + expect(categoryRepo.update).toHaveBeenCalledWith('cat-1', { + name: 'Reasoning', + }); + expect(result).toBe(updated); + }); + + it('deletes a category by id', async () => { + categoryRepo.delete.mockResolvedValue({} as any); + + await service.remove('cat-1'); + + expect(categoryRepo.delete).toHaveBeenCalledWith('cat-1'); + }); +}); diff --git a/backend/src/progress/providers/progress-calculation.provider.spec.ts b/backend/src/progress/providers/progress-calculation.provider.spec.ts new file mode 100644 index 00000000..d3f9d03f --- /dev/null +++ b/backend/src/progress/providers/progress-calculation.provider.spec.ts @@ -0,0 +1,302 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { ProgressCalculationProvider } from './progress-calculation.provider'; +import { Puzzle } from '../../puzzles/entities/puzzle.entity'; +import { UserProgress } from '../entities/progress.entity'; +import { User } from '../../users/user.entity'; +import { DailyQuest } from '../../quests/entities/daily-quest.entity'; +import { XpLevelService } from '../../users/providers/xp-level.service'; +import { ScoreService } from '../../score/providers/score.service'; +import { IdempotencyService } from '../../common/idempotency/idempotency.service'; +import { PuzzleDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum'; +import { SubmitAnswerDto } from '../dtos/submit-answer.dto'; + +function makePuzzle(overrides?: Partial): Puzzle { + return { + id: 'puzzle-1', + question: 'What is 2+2?', + options: ['3', '4'], + correctAnswer: '4', + points: 10, + timeLimit: 60, + difficulty: PuzzleDifficulty.BEGINNER, + categoryId: 'cat-1', + category: null as any, + createdAt: new Date(), + updatedAt: new Date(), + progressRecords: [], + ...overrides, + }; +} + +describe('ProgressCalculationProvider', () => { + let provider: ProgressCalculationProvider; + let puzzleRepo: jest.Mocked>; + let progressRepo: jest.Mocked>; + let userRepo: jest.Mocked>; + let dailyQuestRepo: jest.Mocked>; + let xpLevelService: { addXp: jest.Mock }; + let idempotencyService: { execute: jest.Mock }; + + beforeEach(async () => { + xpLevelService = { addXp: jest.fn() }; + idempotencyService = { + execute: jest.fn(async (_key: string, fn: () => Promise) => ({ + duplicate: false, + data: await fn(), + })), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProgressCalculationProvider, + ScoreService, + { provide: XpLevelService, useValue: xpLevelService }, + { provide: IdempotencyService, useValue: idempotencyService }, + { provide: getRepositoryToken(Puzzle), useValue: { findOne: jest.fn() } }, + { + provide: getRepositoryToken(UserProgress), + useValue: { find: jest.fn(), findOne: jest.fn(), create: jest.fn(), save: jest.fn() }, + }, + { provide: getRepositoryToken(User), useValue: { findOne: jest.fn() } }, + { + provide: getRepositoryToken(DailyQuest), + useValue: { findOne: jest.fn(), save: jest.fn() }, + }, + ], + }).compile(); + + provider = module.get(ProgressCalculationProvider); + puzzleRepo = module.get(getRepositoryToken(Puzzle)); + progressRepo = module.get(getRepositoryToken(UserProgress)); + userRepo = module.get(getRepositoryToken(User)); + dailyQuestRepo = module.get(getRepositoryToken(DailyQuest)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('validateAnswer', () => { + it('trims whitespace and compares case-insensitively', () => { + const result = provider.validateAnswer(' Four ', 'FOUR'); + + expect(result.isCorrect).toBe(true); + expect(result.normalizedAnswer).toBe('four'); + }); + + it('returns isCorrect=false for a mismatched answer', () => { + const result = provider.validateAnswer('three', 'four'); + + expect(result.isCorrect).toBe(false); + expect(result.normalizedAnswer).toBe('three'); + }); + }); + + describe('calculatePoints', () => { + it('returns 0 for an incorrect answer', () => { + expect(provider.calculatePoints(makePuzzle(), 10, false)).toBe(0); + }); + + it('applies a time bonus when the answer is under the limit', () => { + // BEGINNER base 10, timeSpent 0, timeLimit 60 → 10 * (1 + 0.5) = 15 + expect(provider.calculatePoints(makePuzzle(), 0, true)).toBe(15); + }); + + it('awards only base points when timeSpent meets the limit', () => { + expect(provider.calculatePoints(makePuzzle(), 60, true)).toBe(10); + }); + + it('uses difficulty-based base points for EXPERT', () => { + const puzzle = makePuzzle({ + difficulty: PuzzleDifficulty.EXPERT, + timeLimit: 30, + }); + // EXPERT 100, no time bonus at timeSpent === timeLimit + expect(provider.calculatePoints(puzzle, 30, true)).toBe(100); + }); + }); + + describe('processAnswerSubmission', () => { + const dto: SubmitAnswerDto = { + userId: 'user-1', + puzzleId: 'puzzle-1', + categoryId: 'cat-1', + userAnswer: '4', + timeSpent: 60, + }; + + it('throws NotFoundException when the puzzle does not exist', async () => { + puzzleRepo.findOne.mockResolvedValue(null); + + await expect(provider.processAnswerSubmission(dto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('awards XP and records progress for a correct answer', async () => { + const puzzle = makePuzzle(); + const progress = { id: 1, isCorrect: true, pointsEarned: 10 } as UserProgress; + puzzleRepo.findOne.mockResolvedValue(puzzle); + userRepo.findOne.mockResolvedValue({ + id: 'user-1', + streak: null, + } as unknown as User); + dailyQuestRepo.findOne.mockResolvedValue(null); + progressRepo.create.mockReturnValue(progress); + progressRepo.save.mockResolvedValue(progress); + xpLevelService.addXp.mockResolvedValue({ + levelUp: false, + currentLevel: 1, + currentXp: 10, + previousLevel: 1, + }); + + const result = await provider.processAnswerSubmission(dto); + + expect(result.validation.isCorrect).toBe(true); + expect(result.validation.pointsEarned).toBe(10); + expect(xpLevelService.addXp).toHaveBeenCalledWith('user-1', 10); + expect(progressRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + puzzleId: 'puzzle-1', + isCorrect: true, + pointsEarned: 10, + }), + ); + }); + + it('applies a 10% streak bonus at 3 days and 25% at 7 days', async () => { + const puzzle = makePuzzle(); + puzzleRepo.findOne.mockResolvedValue(puzzle); + dailyQuestRepo.findOne.mockResolvedValue(null); + (progressRepo.create as jest.Mock).mockImplementation((row) => row); + (progressRepo.save as jest.Mock).mockImplementation((row) => + Promise.resolve(row), + ); + xpLevelService.addXp.mockResolvedValue({}); + + userRepo.findOne.mockResolvedValue({ + id: 'user-1', + streak: { currentStreak: 3 }, + } as unknown as User); + const threeDay = await provider.processAnswerSubmission(dto); + expect(threeDay.validation.pointsEarned).toBe(11); // round(10 * 1.1) + + userRepo.findOne.mockResolvedValue({ + id: 'user-1', + streak: { currentStreak: 7 }, + } as unknown as User); + const sevenDay = await provider.processAnswerSubmission(dto); + expect(sevenDay.validation.pointsEarned).toBe(13); // round(10 * 1.25) + }); + + it('does not award XP for an incorrect answer', async () => { + puzzleRepo.findOne.mockResolvedValue(makePuzzle()); + userRepo.findOne.mockResolvedValue({ id: 'user-1' } as unknown as User); + dailyQuestRepo.findOne.mockResolvedValue(null); + (progressRepo.create as jest.Mock).mockImplementation((row) => row); + (progressRepo.save as jest.Mock).mockImplementation((row) => + Promise.resolve(row), + ); + + const result = await provider.processAnswerSubmission({ + ...dto, + userAnswer: 'wrong', + }); + + expect(result.validation.isCorrect).toBe(false); + expect(result.validation.pointsEarned).toBe(0); + expect(xpLevelService.addXp).not.toHaveBeenCalled(); + }); + + it('increments daily-quest progress for a correct quest puzzle', async () => { + const quest = { + id: 9, + isCompleted: false, + completedQuestions: 9, + totalQuestions: 10, + questPuzzles: [{ puzzleId: 'puzzle-1' }], + } as DailyQuest; + puzzleRepo.findOne.mockResolvedValue(makePuzzle()); + userRepo.findOne.mockResolvedValue({ + id: 'user-1', + streak: null, + } as unknown as User); + dailyQuestRepo.findOne.mockResolvedValue(quest); + progressRepo.findOne.mockResolvedValue(null); + (progressRepo.create as jest.Mock).mockImplementation((row) => row); + (progressRepo.save as jest.Mock).mockImplementation((row) => + Promise.resolve(row), + ); + dailyQuestRepo.save.mockResolvedValue(quest); + xpLevelService.addXp.mockResolvedValue({}); + + await provider.processAnswerSubmission(dto); + + expect(quest.completedQuestions).toBe(10); + expect(quest.isCompleted).toBe(true); + expect(xpLevelService.addXp).toHaveBeenCalledWith('user-1', 50); + expect(dailyQuestRepo.save).toHaveBeenCalledWith(quest); + }); + + it('returns the cached result on a duplicate idempotency key without re-processing', async () => { + const cached = { + userProgress: { id: 1 } as UserProgress, + validation: { isCorrect: true, pointsEarned: 10, normalizedAnswer: '4' }, + }; + idempotencyService.execute.mockResolvedValue({ + duplicate: true, + data: cached, + }); + + const result = await provider.processAnswerSubmission({ + ...dto, + idempotencyKey: 'client-key', + }); + + expect(result).toBe(cached); + expect(puzzleRepo.findOne).not.toHaveBeenCalled(); + expect(xpLevelService.addXp).not.toHaveBeenCalled(); + }); + }); + + describe('getUserProgressStats', () => { + it('returns zeros when the user has no attempts in the category', async () => { + progressRepo.find.mockResolvedValue([]); + + await expect( + provider.getUserProgressStats('user-1', 'cat-1'), + ).resolves.toEqual({ + totalAttempts: 0, + correctAttempts: 0, + totalPoints: 0, + averageTimeSpent: 0, + accuracy: 0, + }); + }); + + it('aggregates attempts, points, time, and accuracy', async () => { + progressRepo.find.mockResolvedValue([ + { isCorrect: true, pointsEarned: 10, timeSpent: 20 }, + { isCorrect: false, pointsEarned: 0, timeSpent: 40 }, + ] as UserProgress[]); + + const stats = await provider.getUserProgressStats('user-1', 'cat-1'); + + expect(progressRepo.find).toHaveBeenCalledWith({ + where: { userId: 'user-1', categoryId: 'cat-1' }, + }); + expect(stats).toEqual({ + totalAttempts: 2, + correctAttempts: 1, + totalPoints: 10, + averageTimeSpent: 30, + accuracy: 50, + }); + }); + }); +}); diff --git a/backend/src/progress/providers/progress-stats.providers.spec.ts b/backend/src/progress/providers/progress-stats.providers.spec.ts new file mode 100644 index 00000000..6281cc5d --- /dev/null +++ b/backend/src/progress/providers/progress-stats.providers.spec.ts @@ -0,0 +1,177 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GetOverallStatsProvider } from './get-overall-stats.provider'; +import { GetCategoryStatsProvider } from './get-category-stats.provider'; +import { GetProgressHistoryProvider } from './get-progress-history.provider'; +import { UserProgress } from '../entities/progress.entity'; + +function makeProgress(overrides?: Partial): UserProgress { + return { + id: 1, + userId: 'user-1', + puzzleId: 'puzzle-1', + categoryId: 'cat-1', + isCorrect: true, + pointsEarned: 10, + timeSpent: 20, + attemptedAt: new Date('2026-08-01T00:00:00Z'), + ...overrides, + } as UserProgress; +} + +describe('GetOverallStatsProvider', () => { + let provider: GetOverallStatsProvider; + let progressRepo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetOverallStatsProvider, + { provide: getRepositoryToken(UserProgress), useValue: { find: jest.fn() } }, + ], + }).compile(); + provider = module.get(GetOverallStatsProvider); + progressRepo = module.get(getRepositoryToken(UserProgress)); + }); + + afterEach(() => jest.clearAllMocks()); + + it('returns zeroed stats when the user has no progress', async () => { + progressRepo.find.mockResolvedValue([]); + + await expect(provider.getOverallStats('user-1')).resolves.toEqual({ + totalAttempts: 0, + totalCorrect: 0, + accuracy: 0, + totalPointsEarned: 0, + totalTimeSpent: 0, + }); + }); + + it('aggregates attempts and rounds accuracy to an integer percent', async () => { + progressRepo.find.mockResolvedValue([ + makeProgress({ isCorrect: true, pointsEarned: 10, timeSpent: 15 }), + makeProgress({ isCorrect: true, pointsEarned: 25, timeSpent: 20 }), + makeProgress({ isCorrect: false, pointsEarned: 0, timeSpent: 40 }), + ]); + + const stats = await provider.getOverallStats('user-1'); + + expect(progressRepo.find).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + }); + expect(stats).toEqual({ + totalAttempts: 3, + totalCorrect: 2, + accuracy: 67, + totalPointsEarned: 35, + totalTimeSpent: 75, + }); + }); +}); + +describe('GetCategoryStatsProvider', () => { + let provider: GetCategoryStatsProvider; + let progressRepo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetCategoryStatsProvider, + { provide: getRepositoryToken(UserProgress), useValue: { find: jest.fn() } }, + ], + }).compile(); + provider = module.get(GetCategoryStatsProvider); + progressRepo = module.get(getRepositoryToken(UserProgress)); + }); + + afterEach(() => jest.clearAllMocks()); + + it('returns an empty category name and zeros when there are no records', async () => { + progressRepo.find.mockResolvedValue([]); + + await expect(provider.getCategoryStats('user-1', 'cat-1')).resolves.toEqual({ + categoryId: 'cat-1', + categoryName: '', + totalAttempts: 0, + correctAnswers: 0, + accuracy: 0, + }); + }); + + it('computes per-category accuracy and name from the first record', async () => { + progressRepo.find.mockResolvedValue([ + makeProgress({ + isCorrect: true, + category: { name: 'Logic' } as UserProgress['category'], + }), + makeProgress({ isCorrect: false }), + ]); + + const stats = await provider.getCategoryStats('user-1', 'cat-1'); + + expect(progressRepo.find).toHaveBeenCalledWith({ + where: { userId: 'user-1', categoryId: 'cat-1' }, + relations: ['category'], + }); + expect(stats).toEqual({ + categoryId: 'cat-1', + categoryName: 'Logic', + totalAttempts: 2, + correctAnswers: 1, + accuracy: 50, + }); + }); +}); + +describe('GetProgressHistoryProvider', () => { + let provider: GetProgressHistoryProvider; + let progressRepo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetProgressHistoryProvider, + { + provide: getRepositoryToken(UserProgress), + useValue: { findAndCount: jest.fn() }, + }, + ], + }).compile(); + provider = module.get(GetProgressHistoryProvider); + progressRepo = module.get(getRepositoryToken(UserProgress)); + }); + + afterEach(() => jest.clearAllMocks()); + + it('returns paginated history ordered by attemptedAt DESC', async () => { + const rows = [makeProgress()]; + progressRepo.findAndCount.mockResolvedValue([rows, 1]); + + const result = await provider.getProgressHistory('user-1', 2, 5); + + expect(progressRepo.findAndCount).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + relations: ['puzzle'], + order: { attemptedAt: 'DESC' }, + skip: 5, + take: 5, + }); + expect(result).toEqual({ + data: rows, + meta: { page: 2, limit: 5, total: 1 }, + }); + }); + + it('defaults to page 1 and limit 10', async () => { + progressRepo.findAndCount.mockResolvedValue([[], 0]); + + const result = await provider.getProgressHistory('user-1'); + + expect(progressRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 10 }), + ); + expect(result.meta).toEqual({ page: 1, limit: 10, total: 0 }); + }); +}); diff --git a/backend/src/progress/providers/progress.service.spec.ts b/backend/src/progress/providers/progress.service.spec.ts new file mode 100644 index 00000000..20c95d2a --- /dev/null +++ b/backend/src/progress/providers/progress.service.spec.ts @@ -0,0 +1,57 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProgressService } from '../progress.service'; +import { ProgressCalculationProvider } from './progress-calculation.provider'; +import { SubmitAnswerDto } from '../dtos/submit-answer.dto'; + +describe('ProgressService', () => { + let service: ProgressService; + let calculation: { + processAnswerSubmission: jest.Mock; + getUserProgressStats: jest.Mock; + validateAnswer: jest.Mock; + }; + + beforeEach(async () => { + calculation = { + processAnswerSubmission: jest.fn(), + getUserProgressStats: jest.fn(), + validateAnswer: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProgressService, + { provide: ProgressCalculationProvider, useValue: calculation }, + ], + }).compile(); + + service = module.get(ProgressService); + }); + + it('delegates submitAnswer to processAnswerSubmission', async () => { + const dto = { userId: 'user-1' } as SubmitAnswerDto; + const payload = { validation: { isCorrect: true } }; + calculation.processAnswerSubmission.mockResolvedValue(payload); + + await expect(service.submitAnswer(dto)).resolves.toBe(payload); + expect(calculation.processAnswerSubmission).toHaveBeenCalledWith(dto); + }); + + it('delegates getUserStats', async () => { + const stats = { totalAttempts: 1 }; + calculation.getUserProgressStats.mockResolvedValue(stats); + + await expect(service.getUserStats('user-1', 'cat-1')).resolves.toBe(stats); + expect(calculation.getUserProgressStats).toHaveBeenCalledWith( + 'user-1', + 'cat-1', + ); + }); + + it('delegates validateAnswer', () => { + calculation.validateAnswer.mockReturnValue({ isCorrect: true }); + + expect(service.validateAnswer('a', 'A')).toEqual({ isCorrect: true }); + expect(calculation.validateAnswer).toHaveBeenCalledWith('a', 'A'); + }); +}); diff --git a/backend/src/puzzles/providers/create-puzzle.provider.spec.ts b/backend/src/puzzles/providers/create-puzzle.provider.spec.ts new file mode 100644 index 00000000..55e520ea --- /dev/null +++ b/backend/src/puzzles/providers/create-puzzle.provider.spec.ts @@ -0,0 +1,163 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { CreatePuzzleProvider } from './create-puzzle.provider'; +import { Puzzle } from '../entities/puzzle.entity'; +import { Category } from '../../categories/entities/category.entity'; +import { CreatePuzzleDto } from '../dtos/create-puzzle.dto'; +import { + PuzzleDifficulty, + getPointsByDifficulty, +} from '../enums/puzzle-difficulty.enum'; + +function makeDto(overrides?: Partial): CreatePuzzleDto { + const dto = new CreatePuzzleDto(); + dto.question = 'What has keys but cannot open locks?'; + dto.options = ['A piano', 'A map', 'A keyboard', 'A code']; + dto.correctAnswer = 'A piano'; + dto.difficulty = PuzzleDifficulty.BEGINNER; + dto.categoryId = 'cat-uuid-1'; + dto.timeLimit = 60; + Object.assign(dto, overrides); + return dto; +} + +function makeCategory(overrides?: Partial): Category { + return { + id: 'cat-uuid-1', + name: 'Logic', + isActive: true, + createdAt: new Date(), + puzzles: [], + ...overrides, + }; +} + +describe('CreatePuzzleProvider', () => { + let provider: CreatePuzzleProvider; + let puzzleRepo: jest.Mocked>; + let categoryRepo: jest.Mocked>; + + beforeEach(async () => { + const mockPuzzleRepo = { + create: jest.fn(), + save: jest.fn(), + }; + const mockCategoryRepo = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreatePuzzleProvider, + { provide: getRepositoryToken(Puzzle), useValue: mockPuzzleRepo }, + { provide: getRepositoryToken(Category), useValue: mockCategoryRepo }, + ], + }).compile(); + + provider = module.get(CreatePuzzleProvider); + puzzleRepo = module.get(getRepositoryToken(Puzzle)); + categoryRepo = module.get(getRepositoryToken(Category)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('maps the DTO onto a puzzle entity and persists it', async () => { + const dto = makeDto({ + explanation: 'A piano has keys but cannot open locks.', + }); + const category = makeCategory(); + const created = { ...dto, id: 'puzzle-1' } as unknown as Puzzle; + + categoryRepo.findOne.mockResolvedValue(category); + puzzleRepo.create.mockReturnValue(created); + puzzleRepo.save.mockResolvedValue(created); + + const result = await provider.execute(dto); + + expect(categoryRepo.findOne).toHaveBeenCalledWith({ + where: { id: dto.categoryId }, + }); + expect(puzzleRepo.create).toHaveBeenCalledWith(dto); + expect(puzzleRepo.save).toHaveBeenCalledWith(created); + expect(result).toBe(created); + }); + + it.each([ + [PuzzleDifficulty.BEGINNER, 10], + [PuzzleDifficulty.INTERMEDIATE, 25], + [PuzzleDifficulty.ADVANCED, 50], + [PuzzleDifficulty.EXPERT, 100], + ] as const)( + 'sets default points for %s difficulty when points are omitted', + async (difficulty, expectedPoints) => { + const dto = makeDto({ difficulty }); + categoryRepo.findOne.mockResolvedValue(makeCategory()); + (puzzleRepo.create as jest.Mock).mockImplementation((payload) => payload); + (puzzleRepo.save as jest.Mock).mockImplementation((puzzle) => + Promise.resolve(puzzle), + ); + + await provider.execute(dto); + + expect(dto.points).toBe(expectedPoints); + expect(dto.points).toBe(getPointsByDifficulty(difficulty)); + expect(puzzleRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ points: expectedPoints, difficulty }), + ); + }, + ); + + it('preserves an explicit points value instead of overwriting from difficulty', async () => { + const dto = makeDto({ + difficulty: PuzzleDifficulty.BEGINNER, + points: 250, + }); + categoryRepo.findOne.mockResolvedValue(makeCategory()); + (puzzleRepo.create as jest.Mock).mockImplementation((payload) => payload); + (puzzleRepo.save as jest.Mock).mockImplementation((puzzle) => + Promise.resolve(puzzle), + ); + + await provider.execute(dto); + + expect(dto.points).toBe(250); + expect(puzzleRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ points: 250 }), + ); + }); + + it('throws BadRequestException when the category does not exist', async () => { + const dto = makeDto(); + categoryRepo.findOne.mockResolvedValue(null); + + await expect(provider.execute(dto)).rejects.toThrow(BadRequestException); + expect(puzzleRepo.create).not.toHaveBeenCalled(); + expect(puzzleRepo.save).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when the category is inactive', async () => { + const dto = makeDto(); + categoryRepo.findOne.mockResolvedValue(makeCategory({ isActive: false })); + + await expect(provider.execute(dto)).rejects.toThrow(BadRequestException); + expect(puzzleRepo.create).not.toHaveBeenCalled(); + }); + + it('throws InternalServerErrorException when save fails', async () => { + const dto = makeDto(); + categoryRepo.findOne.mockResolvedValue(makeCategory()); + puzzleRepo.create.mockReturnValue(dto as unknown as Puzzle); + puzzleRepo.save.mockRejectedValue(new Error('db down')); + + await expect(provider.execute(dto)).rejects.toThrow( + InternalServerErrorException, + ); + }); +}); diff --git a/backend/src/puzzles/providers/getAll-puzzle.provider.spec.ts b/backend/src/puzzles/providers/getAll-puzzle.provider.spec.ts new file mode 100644 index 00000000..9e593628 --- /dev/null +++ b/backend/src/puzzles/providers/getAll-puzzle.provider.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GetAllPuzzlesProvider } from './getAll-puzzle.provider'; +import { Puzzle } from '../entities/puzzle.entity'; +import { PuzzleDifficulty } from '../enums/puzzle-difficulty.enum'; +import { PuzzleQueryDto } from '../dtos/puzzle-query.dto'; + +function makePuzzle(overrides?: Partial): Puzzle { + return { + id: 'puzzle-1', + question: 'What is 2+2?', + options: ['3', '4'], + correctAnswer: '4', + points: 10, + timeLimit: 60, + difficulty: PuzzleDifficulty.BEGINNER, + categoryId: 'cat-1', + category: { id: 'cat-1', name: 'Logic' } as Puzzle['category'], + createdAt: new Date('2026-08-01T00:00:00Z'), + updatedAt: new Date('2026-08-01T00:00:00Z'), + progressRecords: [], + ...overrides, + }; +} + +describe('GetAllPuzzlesProvider', () => { + let provider: GetAllPuzzlesProvider; + let puzzleRepo: jest.Mocked>; + + beforeEach(async () => { + const mockPuzzleRepo = { + findAndCount: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetAllPuzzlesProvider, + { provide: getRepositoryToken(Puzzle), useValue: mockPuzzleRepo }, + ], + }).compile(); + + provider = module.get(GetAllPuzzlesProvider); + puzzleRepo = module.get(getRepositoryToken(Puzzle)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns paginated puzzles with default page=1 and limit=10', async () => { + const puzzles = [makePuzzle()]; + puzzleRepo.findAndCount.mockResolvedValue([puzzles, 1]); + + const result = await provider.findAll({} as PuzzleQueryDto); + + expect(puzzleRepo.findAndCount).toHaveBeenCalledWith({ + where: {}, + relations: ['category'], + order: { createdAt: 'DESC' }, + skip: 0, + take: 10, + }); + expect(result).toEqual({ + data: puzzles, + meta: { page: 1, limit: 10, total: 1 }, + }); + }); + + it('filters by categoryId and difficulty when provided', async () => { + puzzleRepo.findAndCount.mockResolvedValue([[], 0]); + const query: PuzzleQueryDto = { + categoryId: 'cat-9', + difficulty: PuzzleDifficulty.EXPERT, + page: 2, + limit: 5, + }; + + await provider.findAll(query); + + expect(puzzleRepo.findAndCount).toHaveBeenCalledWith({ + where: { + categoryId: 'cat-9', + difficulty: PuzzleDifficulty.EXPERT, + }, + relations: ['category'], + order: { createdAt: 'DESC' }, + skip: 5, + take: 5, + }); + }); + + it('computes skip from page and limit', async () => { + puzzleRepo.findAndCount.mockResolvedValue([[makePuzzle()], 21]); + + const result = await provider.findAll({ page: 3, limit: 7 }); + + expect(puzzleRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 14, take: 7 }), + ); + expect(result.meta).toEqual({ page: 3, limit: 7, total: 21 }); + }); +}); diff --git a/backend/src/puzzles/providers/puzzles.service.spec.ts b/backend/src/puzzles/providers/puzzles.service.spec.ts new file mode 100644 index 00000000..809dcfa4 --- /dev/null +++ b/backend/src/puzzles/providers/puzzles.service.spec.ts @@ -0,0 +1,132 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { PuzzlesService } from './puzzles.service'; +import { CreatePuzzleProvider } from './create-puzzle.provider'; +import { GetAllPuzzlesProvider } from './getAll-puzzle.provider'; +import { Puzzle } from '../entities/puzzle.entity'; +import { CreatePuzzleDto } from '../dtos/create-puzzle.dto'; +import { PuzzleQueryDto } from '../dtos/puzzle-query.dto'; +import { PuzzleDifficulty } from '../enums/puzzle-difficulty.enum'; + +function makePuzzle(overrides?: Partial): Puzzle { + return { + id: 'puzzle-1', + question: 'What is 2+2?', + options: ['3', '4'], + correctAnswer: '4', + points: 10, + timeLimit: 60, + difficulty: PuzzleDifficulty.BEGINNER, + categoryId: 'cat-1', + category: { id: 'cat-1', name: 'Logic', isActive: true } as Puzzle['category'], + createdAt: new Date(), + updatedAt: new Date(), + progressRecords: [], + ...overrides, + }; +} + +describe('PuzzlesService', () => { + let service: PuzzlesService; + let puzzleRepo: jest.Mocked>; + let createPuzzleProvider: { execute: jest.Mock }; + let allPuzzlesProvider: { findAll: jest.Mock }; + + beforeEach(async () => { + createPuzzleProvider = { execute: jest.fn() }; + allPuzzlesProvider = { findAll: jest.fn() }; + const mockPuzzleRepo = { + findOne: jest.fn(), + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PuzzlesService, + { provide: CreatePuzzleProvider, useValue: createPuzzleProvider }, + { provide: GetAllPuzzlesProvider, useValue: allPuzzlesProvider }, + { provide: getRepositoryToken(Puzzle), useValue: mockPuzzleRepo }, + ], + }).compile(); + + service = module.get(PuzzlesService); + puzzleRepo = module.get(getRepositoryToken(Puzzle)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('delegates create to CreatePuzzleProvider', async () => { + const dto = { question: 'q' } as CreatePuzzleDto; + const puzzle = makePuzzle(); + createPuzzleProvider.execute.mockResolvedValue(puzzle); + + const result = await service.create(dto); + + expect(createPuzzleProvider.execute).toHaveBeenCalledWith(dto); + expect(result).toBe(puzzle); + }); + + it('delegates findAll to GetAllPuzzlesProvider', async () => { + const query: PuzzleQueryDto = { page: 1, limit: 10 }; + const payload = { data: [], meta: { page: 1, limit: 10, total: 0 } }; + allPuzzlesProvider.findAll.mockResolvedValue(payload); + + const result = await service.findAll(query); + + expect(allPuzzlesProvider.findAll).toHaveBeenCalledWith(query); + expect(result).toBe(payload); + }); + + describe('getPuzzleById', () => { + it('returns the puzzle with its category relation', async () => { + const puzzle = makePuzzle(); + puzzleRepo.findOne.mockResolvedValue(puzzle); + + const result = await service.getPuzzleById('puzzle-1'); + + expect(puzzleRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'puzzle-1' }, + relations: ['category'], + }); + expect(result).toBe(puzzle); + }); + + it('throws NotFoundException when the puzzle does not exist', async () => { + puzzleRepo.findOne.mockResolvedValue(null); + + await expect(service.getPuzzleById('missing')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getDailyQuestPuzzles', () => { + it('returns at most 5 puzzles from active categories', async () => { + const puzzles = Array.from({ length: 7 }, (_, i) => + makePuzzle({ id: `puzzle-${i}` }), + ); + puzzleRepo.find.mockResolvedValue(puzzles); + + const result = await service.getDailyQuestPuzzles(); + + expect(puzzleRepo.find).toHaveBeenCalledWith({ + where: { category: { isActive: true } }, + relations: ['category'], + }); + expect(result).toHaveLength(5); + }); + + it('returns all puzzles when fewer than 5 exist', async () => { + const puzzles = [makePuzzle(), makePuzzle({ id: 'puzzle-2' })]; + puzzleRepo.find.mockResolvedValue(puzzles); + + const result = await service.getDailyQuestPuzzles(); + + expect(result).toHaveLength(2); + }); + }); +}); diff --git a/backend/src/quests/providers/complete-daily-quest.provider.spec.ts b/backend/src/quests/providers/complete-daily-quest.provider.spec.ts new file mode 100644 index 00000000..3e520b90 --- /dev/null +++ b/backend/src/quests/providers/complete-daily-quest.provider.spec.ts @@ -0,0 +1,190 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { CompleteDailyQuestProvider } from './complete-daily-quest.provider'; +import { DailyQuest } from '../entities/daily-quest.entity'; +import { User } from '../../users/user.entity'; +import { UpdateStreakProvider } from '../../streak/providers/update-streak.provider'; +import { Streak } from '../../streak/entities/streak.entity'; +import { getDateString } from '../../shared/utils/date.util'; + +jest.mock('../../shared/utils/date.util', () => ({ + getDateString: jest.fn(), +})); + +const TODAY = '2026-08-28'; +const USER_ID = 'user-1'; +const TIMEZONE = 'UTC'; + +function makeQuest(overrides?: Partial): DailyQuest { + return { + id: 1, + userId: USER_ID, + questDate: TODAY, + totalQuestions: 10, + completedQuestions: 10, + isCompleted: false, + pointsEarned: 200, + createdAt: new Date(), + completedAt: undefined, + user: null as any, + questPuzzles: [], + progressRecords: [], + ...overrides, + }; +} + +function makeStreak(overrides?: Partial): Streak { + return { + id: 1, + userId: 1, + currentStreak: 4, + longestStreak: 7, + lastActivityDate: TODAY, + streakDates: [TODAY], + updatedAt: new Date(), + user: null as any, + ...overrides, + }; +} + +describe('CompleteDailyQuestProvider', () => { + let provider: CompleteDailyQuestProvider; + let updateStreakProvider: { + getStreak: jest.Mock; + updateStreak: jest.Mock; + }; + let dataSource: { transaction: jest.Mock }; + + beforeEach(async () => { + (getDateString as jest.Mock).mockReturnValue(TODAY); + updateStreakProvider = { + getStreak: jest.fn(), + updateStreak: jest.fn(), + }; + dataSource = { transaction: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CompleteDailyQuestProvider, + { provide: UpdateStreakProvider, useValue: updateStreakProvider }, + { provide: DataSource, useValue: dataSource }, + ], + }).compile(); + + provider = module.get(CompleteDailyQuestProvider); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + function mockTransaction(manager: { + findOne: jest.Mock; + save: jest.Mock; + }) { + dataSource.transaction.mockImplementation((cb: any) => cb(manager)); + } + + it('awards bonus XP, levels the user, and updates the streak on first completion', async () => { + const quest = makeQuest(); + const user = { id: USER_ID, xp: 50, level: 1 } as User; + const manager = { + findOne: jest.fn((entity: unknown) => { + if (entity === DailyQuest) return Promise.resolve(quest); + if (entity === User) return Promise.resolve(user); + return Promise.resolve(null); + }), + save: jest.fn((...args: any[]) => Promise.resolve(args[1])), + }; + mockTransaction(manager); + updateStreakProvider.updateStreak.mockResolvedValue(makeStreak()); + + const result = await provider.execute(USER_ID, TIMEZONE); + + expect(getDateString).toHaveBeenCalledWith(TIMEZONE, 0); + expect(quest.isCompleted).toBe(true); + expect(quest.completedAt).toBeInstanceOf(Date); + expect(quest.pointsEarned).toBe(300); + expect(user.xp).toBe(150); + expect(user.level).toBe(2); + expect(updateStreakProvider.updateStreak).toHaveBeenCalledWith( + USER_ID, + TIMEZONE, + ); + expect(result.success).toBe(true); + expect(result.bonusXp).toBe(100); + expect(result.totalXp).toBe(300); + expect(result.streakInfo.currentStreak).toBe(4); + expect(result.message).toMatch(/completed successfully/i); + }); + + it('is idempotent: already-completed quests skip XP and streak mutation', async () => { + const completedAt = new Date('2026-08-28T12:00:00Z'); + const quest = makeQuest({ + isCompleted: true, + completedAt, + pointsEarned: 300, + }); + const manager = { + findOne: jest.fn(() => Promise.resolve(quest)), + save: jest.fn(), + }; + mockTransaction(manager); + updateStreakProvider.getStreak.mockResolvedValue(makeStreak()); + + const result = await provider.execute(USER_ID, TIMEZONE); + + expect(manager.save).not.toHaveBeenCalled(); + expect(updateStreakProvider.updateStreak).not.toHaveBeenCalled(); + expect(updateStreakProvider.getStreak).toHaveBeenCalledWith(USER_ID); + expect(result.success).toBe(true); + expect(result.message).toBe('Daily quest already completed'); + expect(result.bonusXp).toBe(100); + expect(result.totalXp).toBe(300); + expect(result.completedAt).toBe(completedAt); + }); + + it('throws NotFoundException when today\'s quest does not exist', async () => { + const manager = { + findOne: jest.fn(() => Promise.resolve(null)), + save: jest.fn(), + }; + mockTransaction(manager); + + await expect(provider.execute(USER_ID, TIMEZONE)).rejects.toThrow( + NotFoundException, + ); + expect(updateStreakProvider.updateStreak).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when not every question is completed', async () => { + const quest = makeQuest({ completedQuestions: 7, totalQuestions: 10 }); + const manager = { + findOne: jest.fn(() => Promise.resolve(quest)), + save: jest.fn(), + }; + mockTransaction(manager); + + await expect(provider.execute(USER_ID, TIMEZONE)).rejects.toThrow( + BadRequestException, + ); + expect(manager.save).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when the user row is missing during completion', async () => { + const quest = makeQuest(); + const manager = { + findOne: jest.fn((entity: unknown) => { + if (entity === DailyQuest) return Promise.resolve(quest); + return Promise.resolve(null); + }), + save: jest.fn((...args: any[]) => Promise.resolve(args[1])), + }; + mockTransaction(manager); + + await expect(provider.execute(USER_ID, TIMEZONE)).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/backend/src/quests/providers/complete-daily-quest.provider.ts b/backend/src/quests/providers/complete-daily-quest.provider.ts index ad957bee..7d6fdb3f 100644 --- a/backend/src/quests/providers/complete-daily-quest.provider.ts +++ b/backend/src/quests/providers/complete-daily-quest.provider.ts @@ -4,7 +4,6 @@ import { BadRequestException, NotFoundException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { DailyQuest } from '../entities/daily-quest.entity'; import { User } from '../../users/user.entity'; @@ -18,7 +17,6 @@ export class CompleteDailyQuestProvider { private readonly BONUS_XP = 100; constructor( - @InjectRepository(User) private readonly updateStreakProvider: UpdateStreakProvider, private readonly dataSource: DataSource, ) {} diff --git a/backend/src/quests/providers/daily-quest.service.spec.ts b/backend/src/quests/providers/daily-quest.service.spec.ts new file mode 100644 index 00000000..42d63f86 --- /dev/null +++ b/backend/src/quests/providers/daily-quest.service.spec.ts @@ -0,0 +1,59 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DailyQuestService } from './daily-quest.service'; +import { GetTodaysDailyQuestProvider } from './getTodaysDailyQuest.provider'; +import { GetTodaysDailyQuestStatusProvider } from './getTodaysDailyQuestStatus.provider'; +import { CompleteDailyQuestProvider } from './complete-daily-quest.provider'; + +describe('DailyQuestService', () => { + let service: DailyQuestService; + let getTodays: { execute: jest.Mock }; + let getStatus: { execute: jest.Mock }; + let complete: { execute: jest.Mock }; + + beforeEach(async () => { + getTodays = { execute: jest.fn() }; + getStatus = { execute: jest.fn() }; + complete = { execute: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DailyQuestService, + { provide: GetTodaysDailyQuestProvider, useValue: getTodays }, + { provide: GetTodaysDailyQuestStatusProvider, useValue: getStatus }, + { provide: CompleteDailyQuestProvider, useValue: complete }, + ], + }).compile(); + + service = module.get(DailyQuestService); + }); + + it('delegates getTodaysDailyQuest', async () => { + const payload = { id: 1, questDate: '2026-08-28' }; + getTodays.execute.mockResolvedValue(payload); + + const result = await service.getTodaysDailyQuest('user-1', 'UTC'); + + expect(getTodays.execute).toHaveBeenCalledWith('user-1', 'UTC'); + expect(result).toBe(payload); + }); + + it('delegates getTodaysDailyQuestStatus', async () => { + const payload = { totalQuestions: 10, completedQuestions: 0, isCompleted: false }; + getStatus.execute.mockResolvedValue(payload); + + const result = await service.getTodaysDailyQuestStatus('user-1', 'UTC'); + + expect(getStatus.execute).toHaveBeenCalledWith('user-1', 'UTC'); + expect(result).toBe(payload); + }); + + it('delegates completeDailyQuest', async () => { + const payload = { success: true }; + complete.execute.mockResolvedValue(payload); + + const result = await service.completeDailyQuest('user-1', 'UTC'); + + expect(complete.execute).toHaveBeenCalledWith('user-1', 'UTC'); + expect(result).toBe(payload); + }); +}); diff --git a/backend/src/quests/providers/getTodaysDailyQuest.provider.spec.ts b/backend/src/quests/providers/getTodaysDailyQuest.provider.spec.ts new file mode 100644 index 00000000..7d868689 --- /dev/null +++ b/backend/src/quests/providers/getTodaysDailyQuest.provider.spec.ts @@ -0,0 +1,291 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GetTodaysDailyQuestProvider } from './getTodaysDailyQuest.provider'; +import { DailyQuest } from '../entities/daily-quest.entity'; +import { DailyQuestPuzzle } from '../entities/daily-quest-puzzle.entity'; +import { Puzzle } from '../../puzzles/entities/puzzle.entity'; +import { Category } from '../../categories/entities/category.entity'; +import { UserProgress } from '../../progress/entities/progress.entity'; +import { User } from '../../users/user.entity'; +import { PuzzleDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum'; +import { ChallengeLevel } from '../../users/enums/challengeLevel.enum'; +import { getDateString } from '../../shared/utils/date.util'; + +jest.mock('../../shared/utils/date.util', () => ({ + getDateString: jest.fn(), +})); + +const TODAY = '2026-08-28'; +const USER_ID = 'user-1'; +const TIMEZONE = 'America/New_York'; + +function makePuzzle(overrides?: Partial): Puzzle { + return { + id: 'puzzle-1', + question: 'Q?', + options: ['a', 'b'], + correctAnswer: 'a', + points: 10, + timeLimit: 60, + difficulty: PuzzleDifficulty.BEGINNER, + categoryId: 'cat-1', + category: null as any, + createdAt: new Date(), + updatedAt: new Date(), + progressRecords: [], + ...overrides, + }; +} + +function makeQuestPuzzle( + orderIndex: number, + puzzle: Puzzle, +): DailyQuestPuzzle { + return { + id: orderIndex + 1, + dailyQuestId: 1, + puzzleId: puzzle.id, + orderIndex, + puzzle, + dailyQuest: null as any, + }; +} + +function makeDailyQuest(overrides?: Partial): DailyQuest { + const puzzles = [ + makePuzzle({ id: 'puzzle-1' }), + makePuzzle({ id: 'puzzle-2' }), + ]; + return { + id: 1, + userId: USER_ID, + questDate: TODAY, + totalQuestions: 2, + completedQuestions: 0, + isCompleted: false, + pointsEarned: 0, + createdAt: new Date('2026-08-28T00:00:00Z'), + completedAt: undefined, + user: null as any, + questPuzzles: puzzles.map((p, i) => makeQuestPuzzle(i, p)), + progressRecords: [], + ...overrides, + }; +} + +describe('GetTodaysDailyQuestProvider', () => { + let provider: GetTodaysDailyQuestProvider; + let dailyQuestRepo: jest.Mocked>; + let dailyQuestPuzzleRepo: jest.Mocked>; + let puzzleRepo: jest.Mocked>; + let categoryRepo: jest.Mocked>; + let userProgressRepo: jest.Mocked>; + let userRepo: jest.Mocked>; + + beforeEach(async () => { + (getDateString as jest.Mock).mockReturnValue(TODAY); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetTodaysDailyQuestProvider, + { + provide: getRepositoryToken(DailyQuest), + useValue: { findOne: jest.fn(), create: jest.fn(), save: jest.fn() }, + }, + { + provide: getRepositoryToken(DailyQuestPuzzle), + useValue: { create: jest.fn(), save: jest.fn() }, + }, + { + provide: getRepositoryToken(Puzzle), + useValue: { find: jest.fn() }, + }, + { + provide: getRepositoryToken(Category), + useValue: { find: jest.fn() }, + }, + { + provide: getRepositoryToken(UserProgress), + useValue: { find: jest.fn() }, + }, + { + provide: getRepositoryToken(User), + useValue: { findOne: jest.fn() }, + }, + ], + }).compile(); + + provider = module.get(GetTodaysDailyQuestProvider); + dailyQuestRepo = module.get(getRepositoryToken(DailyQuest)); + dailyQuestPuzzleRepo = module.get(getRepositoryToken(DailyQuestPuzzle)); + puzzleRepo = module.get(getRepositoryToken(Puzzle)); + categoryRepo = module.get(getRepositoryToken(Category)); + userProgressRepo = module.get(getRepositoryToken(UserProgress)); + userRepo = module.get(getRepositoryToken(User)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('looks up today\'s quest with a timezone-derived day key and reuses it', async () => { + const existing = makeDailyQuest(); + dailyQuestRepo.findOne.mockResolvedValue(existing); + userProgressRepo.find.mockResolvedValue([]); + + const result = await provider.execute(USER_ID, TIMEZONE); + + expect(getDateString).toHaveBeenCalledWith(TIMEZONE, 0); + expect(dailyQuestRepo.findOne).toHaveBeenCalledWith({ + where: { userId: USER_ID, questDate: TODAY }, + relations: ['questPuzzles', 'questPuzzles.puzzle'], + }); + expect(userRepo.findOne).not.toHaveBeenCalled(); + expect(dailyQuestRepo.save).not.toHaveBeenCalled(); + expect(result.id).toBe(1); + expect(result.questDate).toBe(TODAY); + expect(result.puzzles).toHaveLength(2); + expect(result.puzzles[0].isCompleted).toBe(false); + }); + + it('marks puzzles completed from user-progress rows for this quest', async () => { + const existing = makeDailyQuest(); + dailyQuestRepo.findOne.mockResolvedValue(existing); + userProgressRepo.find.mockResolvedValue([ + { puzzleId: 'puzzle-1' } as UserProgress, + ]); + + const result = await provider.execute(USER_ID, TIMEZONE); + + expect(userProgressRepo.find).toHaveBeenCalledWith({ + where: { dailyQuestId: 1 }, + select: ['puzzleId'], + }); + expect(result.puzzles.find((p) => p.id === 'puzzle-1')?.isCompleted).toBe( + true, + ); + expect(result.puzzles.find((p) => p.id === 'puzzle-2')?.isCompleted).toBe( + false, + ); + }); + + it('generates a new quest keyed to today when none exists', async () => { + const puzzles = Array.from({ length: 10 }, (_, i) => + makePuzzle({ id: `puzzle-${i}` }), + ); + const savedQuest = makeDailyQuest({ + totalQuestions: 10, + questPuzzles: puzzles.map((p, i) => makeQuestPuzzle(i, p)), + }); + + dailyQuestRepo.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(savedQuest); + userRepo.findOne.mockResolvedValue({ + id: USER_ID, + challengeLevel: ChallengeLevel.BEGINNER, + } as unknown as User); + categoryRepo.find.mockResolvedValue([ + { id: 'cat-1', isActive: true } as Category, + ]); + puzzleRepo.find.mockResolvedValue(puzzles); + (dailyQuestRepo.create as jest.Mock).mockImplementation((payload) => payload); + dailyQuestRepo.save.mockResolvedValue({ ...savedQuest, id: 1 }); + (dailyQuestPuzzleRepo.create as jest.Mock).mockImplementation( + (payload) => payload, + ); + dailyQuestPuzzleRepo.save.mockResolvedValue([] as any); + userProgressRepo.find.mockResolvedValue([]); + + const result = await provider.execute(USER_ID, TIMEZONE); + + expect(dailyQuestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: USER_ID, + questDate: TODAY, + totalQuestions: 10, + completedQuestions: 0, + isCompleted: false, + pointsEarned: 0, + }), + ); + expect(puzzleRepo.find).toHaveBeenCalledWith({ + where: expect.objectContaining({ + difficulty: PuzzleDifficulty.BEGINNER, + }), + }); + expect(dailyQuestPuzzleRepo.save).toHaveBeenCalled(); + expect(result.questDate).toBe(TODAY); + expect(result.totalQuestions).toBe(10); + }); + + it('maps challengeLevel to the matching PuzzleDifficulty when generating', async () => { + dailyQuestRepo.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeDailyQuest()); + userRepo.findOne.mockResolvedValue({ + id: USER_ID, + challengeLevel: ChallengeLevel.EXPERT, + } as unknown as User); + categoryRepo.find.mockResolvedValue([ + { id: 'cat-1', isActive: true } as Category, + ]); + puzzleRepo.find.mockResolvedValue([makePuzzle()]); + (dailyQuestRepo.create as jest.Mock).mockImplementation((payload) => payload); + dailyQuestRepo.save.mockResolvedValue(makeDailyQuest()); + (dailyQuestPuzzleRepo.create as jest.Mock).mockImplementation( + (payload) => payload, + ); + dailyQuestPuzzleRepo.save.mockResolvedValue([] as any); + userProgressRepo.find.mockResolvedValue([]); + + await provider.execute(USER_ID, TIMEZONE); + + expect(puzzleRepo.find).toHaveBeenCalledWith({ + where: expect.objectContaining({ + difficulty: PuzzleDifficulty.EXPERT, + }), + }); + }); + + it('uses a different day key when the timezone date changes', async () => { + (getDateString as jest.Mock).mockReturnValue('2026-08-29'); + dailyQuestRepo.findOne.mockResolvedValue( + makeDailyQuest({ questDate: '2026-08-29' }), + ); + userProgressRepo.find.mockResolvedValue([]); + + const result = await provider.execute(USER_ID, 'Pacific/Auckland'); + + expect(getDateString).toHaveBeenCalledWith('Pacific/Auckland', 0); + expect(dailyQuestRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: USER_ID, questDate: '2026-08-29' }, + }), + ); + expect(result.questDate).toBe('2026-08-29'); + }); + + it('throws when the user does not exist during generation', async () => { + dailyQuestRepo.findOne.mockResolvedValue(null); + userRepo.findOne.mockResolvedValue(null); + + await expect(provider.execute(USER_ID, TIMEZONE)).rejects.toThrow( + `User with ID ${USER_ID} not found`, + ); + }); + + it('throws when there are no active categories', async () => { + dailyQuestRepo.findOne.mockResolvedValue(null); + userRepo.findOne.mockResolvedValue({ + id: USER_ID, + challengeLevel: ChallengeLevel.BEGINNER, + } as unknown as User); + categoryRepo.find.mockResolvedValue([]); + + await expect(provider.execute(USER_ID, TIMEZONE)).rejects.toThrow( + 'No active categories available for quest generation', + ); + }); +}); diff --git a/backend/src/quests/providers/getTodaysDailyQuestStatus.provider.spec.ts b/backend/src/quests/providers/getTodaysDailyQuestStatus.provider.spec.ts new file mode 100644 index 00000000..dd5fdada --- /dev/null +++ b/backend/src/quests/providers/getTodaysDailyQuestStatus.provider.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GetTodaysDailyQuestStatusProvider } from './getTodaysDailyQuestStatus.provider'; +import { GetTodaysDailyQuestProvider } from './getTodaysDailyQuest.provider'; +import { DailyQuest } from '../entities/daily-quest.entity'; +import { getDateString } from '../../shared/utils/date.util'; + +jest.mock('../../shared/utils/date.util', () => ({ + getDateString: jest.fn(), +})); + +const TODAY = '2026-08-28'; + +describe('GetTodaysDailyQuestStatusProvider', () => { + let provider: GetTodaysDailyQuestStatusProvider; + let dailyQuestRepo: jest.Mocked>; + let getTodaysDailyQuestProvider: { execute: jest.Mock }; + + beforeEach(async () => { + (getDateString as jest.Mock).mockReturnValue(TODAY); + getTodaysDailyQuestProvider = { execute: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetTodaysDailyQuestStatusProvider, + { + provide: getRepositoryToken(DailyQuest), + useValue: { findOne: jest.fn() }, + }, + { + provide: GetTodaysDailyQuestProvider, + useValue: getTodaysDailyQuestProvider, + }, + ], + }).compile(); + + provider = module.get(GetTodaysDailyQuestStatusProvider); + dailyQuestRepo = module.get(getRepositoryToken(DailyQuest)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns status for an existing quest without generating a new one', async () => { + dailyQuestRepo.findOne.mockResolvedValue({ + id: 1, + totalQuestions: 10, + completedQuestions: 3, + isCompleted: false, + } as DailyQuest); + + const result = await provider.execute('user-1', 'UTC'); + + expect(getDateString).toHaveBeenCalledWith('UTC', 0); + expect(dailyQuestRepo.findOne).toHaveBeenCalledWith({ + where: { userId: 'user-1', questDate: TODAY }, + select: ['id', 'totalQuestions', 'completedQuestions', 'isCompleted'], + }); + expect(getTodaysDailyQuestProvider.execute).not.toHaveBeenCalled(); + expect(result).toEqual({ + totalQuestions: 10, + completedQuestions: 3, + isCompleted: false, + }); + }); + + it('auto-generates today\'s quest when none exists, then returns its status', async () => { + const generated = { + id: 2, + totalQuestions: 10, + completedQuestions: 0, + isCompleted: false, + } as DailyQuest; + dailyQuestRepo.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(generated); + getTodaysDailyQuestProvider.execute.mockResolvedValue({}); + + const result = await provider.execute('user-1', 'UTC'); + + expect(getTodaysDailyQuestProvider.execute).toHaveBeenCalledWith( + 'user-1', + 'UTC', + ); + expect(result).toEqual({ + totalQuestions: 10, + completedQuestions: 0, + isCompleted: false, + }); + }); + + it('throws when generation fails to persist a quest row', async () => { + dailyQuestRepo.findOne.mockResolvedValue(null); + getTodaysDailyQuestProvider.execute.mockResolvedValue({}); + + await expect(provider.execute('user-1', 'UTC')).rejects.toThrow( + 'Failed to retrieve created daily quest for user user-1', + ); + }); +}); diff --git a/backend/src/quests/providers/getTodaysDailyQuestStatus.provider.ts b/backend/src/quests/providers/getTodaysDailyQuestStatus.provider.ts index aefbae7a..6e68cb19 100644 --- a/backend/src/quests/providers/getTodaysDailyQuestStatus.provider.ts +++ b/backend/src/quests/providers/getTodaysDailyQuestStatus.provider.ts @@ -50,9 +50,7 @@ export class GetTodaysDailyQuestStatusProvider { this.logger.log( `No quest found for user ${userId}, auto-generating quest`, ); - // Use the existing provider to generate the full quest - // This ensures consistency with the main getTodaysDailyQuest endpoint - // const fullQuest = await this.getTodaysDailyQuestProvider.execute(userId); + await this.getTodaysDailyQuestProvider.execute(userId, userTimeZone); // Fetch the newly created quest with status fields dailyQuest = await this.dailyQuestRepository.findOne({ diff --git a/backend/src/streak/providers/streaks.service.spec.ts b/backend/src/streak/providers/streaks.service.spec.ts new file mode 100644 index 00000000..c33b724a --- /dev/null +++ b/backend/src/streak/providers/streaks.service.spec.ts @@ -0,0 +1,58 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StreaksService } from './streaks.service'; +import { UpdateStreakProvider } from './update-streak.provider'; +import { Streak } from '../entities/streak.entity'; + +describe('StreaksService', () => { + let service: StreaksService; + let updateStreakProvider: { + getStreak: jest.Mock; + updateStreak: jest.Mock; + }; + + const streak = { + id: 1, + userId: 42, + currentStreak: 3, + longestStreak: 5, + lastActivityDate: '2026-08-28', + streakDates: ['2026-08-26', '2026-08-27', '2026-08-28'], + } as Streak; + + beforeEach(async () => { + updateStreakProvider = { + getStreak: jest.fn(), + updateStreak: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StreaksService, + { provide: UpdateStreakProvider, useValue: updateStreakProvider }, + ], + }).compile(); + + service = module.get(StreaksService); + }); + + it('delegates getStreak to UpdateStreakProvider', async () => { + updateStreakProvider.getStreak.mockResolvedValue(streak); + + const result = await service.getStreak('42'); + + expect(updateStreakProvider.getStreak).toHaveBeenCalledWith('42'); + expect(result).toBe(streak); + }); + + it('delegates updateStreak with userId and timezone', async () => { + updateStreakProvider.updateStreak.mockResolvedValue(streak); + + const result = await service.updateStreak('42', 'America/New_York'); + + expect(updateStreakProvider.updateStreak).toHaveBeenCalledWith( + '42', + 'America/New_York', + ); + expect(result).toBe(streak); + }); +}); diff --git a/backend/src/streak/providers/update-streak.provider.spec.ts b/backend/src/streak/providers/update-streak.provider.spec.ts new file mode 100644 index 00000000..caadc29d --- /dev/null +++ b/backend/src/streak/providers/update-streak.provider.spec.ts @@ -0,0 +1,220 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { UpdateStreakProvider } from './update-streak.provider'; +import { Streak } from '../entities/streak.entity'; +import { getDateString } from '../../shared/utils/date.util'; + +jest.mock('../../shared/utils/date.util', () => ({ + getDateString: jest.fn(), +})); + +const TODAY = '2026-08-28'; +const YESTERDAY = '2026-08-27'; +const TWO_DAYS_AGO = '2026-08-26'; +const USER_ID = '42'; +const TIMEZONE = 'UTC'; + +function makeStreak(overrides?: Partial): Streak { + return { + id: 1, + userId: 42, + currentStreak: 3, + longestStreak: 5, + lastActivityDate: YESTERDAY, + streakDates: [TWO_DAYS_AGO, YESTERDAY], + updatedAt: new Date('2026-08-27T12:00:00Z'), + user: null as any, + ...overrides, + }; +} + +describe('UpdateStreakProvider', () => { + let provider: UpdateStreakProvider; + let streakRepo: jest.Mocked>; + + beforeEach(async () => { + (getDateString as jest.Mock).mockImplementation( + (_tz: string, offsetDays = 0) => { + if (offsetDays === 0) return TODAY; + if (offsetDays === -1) return YESTERDAY; + return TODAY; + }, + ); + + const mockStreakRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UpdateStreakProvider, + { + provide: getRepositoryToken(Streak), + useValue: mockStreakRepo, + }, + ], + }).compile(); + + provider = module.get(UpdateStreakProvider); + streakRepo = module.get(getRepositoryToken(Streak)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('updateStreak', () => { + it('creates a new streak at 1/1 for a first-time user', async () => { + const created = makeStreak({ + id: undefined as any, + currentStreak: 1, + longestStreak: 1, + lastActivityDate: TODAY, + streakDates: [TODAY], + }); + streakRepo.findOne.mockResolvedValue(null); + streakRepo.create.mockReturnValue(created); + streakRepo.save.mockResolvedValue({ ...created, id: 1 }); + + const result = await provider.updateStreak(USER_ID, TIMEZONE); + + expect(getDateString).toHaveBeenCalledWith(TIMEZONE, 0); + expect(streakRepo.findOne).toHaveBeenCalledWith({ + where: { userId: 42 }, + }); + expect(streakRepo.create).toHaveBeenCalledWith({ + userId: 42, + currentStreak: 1, + longestStreak: 1, + lastActivityDate: TODAY, + streakDates: [TODAY], + }); + expect(streakRepo.save).toHaveBeenCalledWith(created); + expect(result.currentStreak).toBe(1); + expect(result.longestStreak).toBe(1); + }); + + it('is idempotent: same-day repeat returns the existing streak without saving', async () => { + const existing = makeStreak({ + lastActivityDate: TODAY, + currentStreak: 4, + longestStreak: 5, + streakDates: [TWO_DAYS_AGO, YESTERDAY, TODAY], + }); + streakRepo.findOne.mockResolvedValue(existing); + + const result = await provider.updateStreak(USER_ID, TIMEZONE); + + expect(result).toBe(existing); + expect(result.currentStreak).toBe(4); + expect(streakRepo.save).not.toHaveBeenCalled(); + expect(streakRepo.create).not.toHaveBeenCalled(); + }); + + it('increments currentStreak when last activity was yesterday', async () => { + const existing = makeStreak({ + lastActivityDate: YESTERDAY, + currentStreak: 3, + longestStreak: 5, + }); + streakRepo.findOne.mockResolvedValue(existing); + (streakRepo.save as jest.Mock).mockImplementation((row: Streak) => + Promise.resolve(row), + ); + + const result = await provider.updateStreak(USER_ID, TIMEZONE); + + expect(getDateString).toHaveBeenCalledWith(TIMEZONE, -1); + expect(result.currentStreak).toBe(4); + expect(result.longestStreak).toBe(5); + expect(result.lastActivityDate).toBe(TODAY); + expect(result.streakDates).toContain(TODAY); + expect(streakRepo.save).toHaveBeenCalledWith(existing); + }); + + it('promotes longestStreak when the continued streak exceeds it', async () => { + const existing = makeStreak({ + lastActivityDate: YESTERDAY, + currentStreak: 5, + longestStreak: 5, + }); + streakRepo.findOne.mockResolvedValue(existing); + (streakRepo.save as jest.Mock).mockImplementation((row: Streak) => + Promise.resolve(row), + ); + + const result = await provider.updateStreak(USER_ID, TIMEZONE); + + expect(result.currentStreak).toBe(6); + expect(result.longestStreak).toBe(6); + }); + + it('resets currentStreak to 1 on a date-boundary gap (missed a day)', async () => { + const existing = makeStreak({ + lastActivityDate: TWO_DAYS_AGO, + currentStreak: 8, + longestStreak: 8, + streakDates: [TWO_DAYS_AGO], + }); + streakRepo.findOne.mockResolvedValue(existing); + (streakRepo.save as jest.Mock).mockImplementation((row: Streak) => + Promise.resolve(row), + ); + + const result = await provider.updateStreak(USER_ID, TIMEZONE); + + expect(result.currentStreak).toBe(1); + expect(result.longestStreak).toBe(8); + expect(result.lastActivityDate).toBe(TODAY); + expect(result.streakDates).toEqual([TWO_DAYS_AGO, TODAY]); + }); + + it('does not duplicate today in streakDates when it is already recorded', async () => { + const existing = makeStreak({ + lastActivityDate: YESTERDAY, + currentStreak: 2, + longestStreak: 2, + streakDates: [YESTERDAY, TODAY], + }); + streakRepo.findOne.mockResolvedValue(existing); + (streakRepo.save as jest.Mock).mockImplementation((row: Streak) => + Promise.resolve(row), + ); + + const result = await provider.updateStreak(USER_ID, TIMEZONE); + + expect(result.streakDates.filter((d) => d === TODAY)).toHaveLength(1); + }); + + it('throws NotFoundException when userId is empty', async () => { + await expect(provider.updateStreak('', TIMEZONE)).rejects.toThrow( + NotFoundException, + ); + expect(streakRepo.findOne).not.toHaveBeenCalled(); + }); + }); + + describe('getStreak', () => { + it('returns the streak row for the parsed numeric userId', async () => { + const existing = makeStreak(); + streakRepo.findOne.mockResolvedValue(existing); + + const result = await provider.getStreak(USER_ID); + + expect(streakRepo.findOne).toHaveBeenCalledWith({ + where: { userId: 42 }, + }); + expect(result).toBe(existing); + }); + + it('returns null when the user has no streak row', async () => { + streakRepo.findOne.mockResolvedValue(null); + + await expect(provider.getStreak(USER_ID)).resolves.toBeNull(); + }); + }); +}); diff --git a/backend/src/users/providers/create-user.service.spec.ts b/backend/src/users/providers/create-user.service.spec.ts new file mode 100644 index 00000000..fc7d9099 --- /dev/null +++ b/backend/src/users/providers/create-user.service.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { CreateUserService } from './create-user.service'; +import { User } from '../user.entity'; +import { HashingProvider } from '../../auth/providers/hashing.provider'; +import { CreateUserDto } from '../dtos/createUserDto'; +import { AuthProvider } from '../../auth/enum/authProvider.enum'; + +function makeDto(overrides?: Partial): CreateUserDto { + return { + email: 'player@example.com', + username: 'player', + fullname: 'Player One', + password: '@Password123', + ...overrides, + }; +} + +describe('CreateUserService', () => { + let service: CreateUserService; + let userRepo: jest.Mocked>; + let hashingProvider: { hashPassword: jest.Mock }; + + beforeEach(async () => { + hashingProvider = { hashPassword: jest.fn() }; + const mockUserRepo = { + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreateUserService, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: HashingProvider, useValue: hashingProvider }, + ], + }).compile(); + + service = module.get(CreateUserService); + userRepo = module.get(getRepositoryToken(User)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('rejects a duplicate email with BadRequestException', async () => { + userRepo.findOneBy.mockResolvedValue({ id: 'existing' } as User); + + await expect(service.execute(makeDto())).rejects.toThrow( + BadRequestException, + ); + await expect(service.execute(makeDto())).rejects.toThrow( + 'Email already in use', + ); + expect(userRepo.create).not.toHaveBeenCalled(); + expect(userRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects an invalid email', async () => { + await expect( + service.execute(makeDto({ email: 'not-an-email' })), + ).rejects.toThrow(BadRequestException); + expect(userRepo.findOneBy).not.toHaveBeenCalled(); + }); + + it('creates a local user after hashing the password', async () => { + const dto = makeDto(); + const saved = { id: 'user-1', email: dto.email } as User; + userRepo.findOneBy.mockResolvedValue(null); + hashingProvider.hashPassword.mockResolvedValue('hashed-secret'); + (userRepo.create as jest.Mock).mockImplementation((payload) => payload); + userRepo.save.mockResolvedValue(saved); + + const result = await service.execute(dto); + + expect(userRepo.findOneBy).toHaveBeenCalledWith({ email: dto.email }); + expect(hashingProvider.hashPassword).toHaveBeenCalledWith('@Password123'); + expect(userRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'player@example.com', + password: 'hashed-secret', + fullname: 'Player One', + }), + ); + expect(result).toBe(saved); + }); + + it('skips email uniqueness for wallet signups', async () => { + const dto = makeDto({ + provider: AuthProvider.WALLET, + email: undefined, + password: undefined, + }); + const saved = { id: 'user-wallet' } as User; + (userRepo.create as jest.Mock).mockImplementation((payload) => payload); + userRepo.save.mockResolvedValue(saved); + + const result = await service.execute(dto); + + expect(userRepo.findOneBy).not.toHaveBeenCalled(); + expect(hashingProvider.hashPassword).not.toHaveBeenCalled(); + expect(result).toBe(saved); + }); + + it('defaults fullname from username when omitted', async () => { + const dto = makeDto({ fullname: undefined, username: 'solo' }); + userRepo.findOneBy.mockResolvedValue(null); + hashingProvider.hashPassword.mockResolvedValue('hashed'); + (userRepo.create as jest.Mock).mockImplementation((payload) => payload); + userRepo.save.mockResolvedValue({ id: 'user-1' } as User); + + await service.execute(dto); + + expect(userRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ fullname: 'solo' }), + ); + }); +}); diff --git a/backend/src/users/providers/users.service.spec.ts b/backend/src/users/providers/users.service.spec.ts new file mode 100644 index 00000000..8e27cfb0 --- /dev/null +++ b/backend/src/users/providers/users.service.spec.ts @@ -0,0 +1,161 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UsersService } from './users.service'; +import { FindOneByEmail } from './find-one-by-email.provider'; +import { FindOneByWallet } from './find-one-by-wallet.provider'; +import { FindAll } from './find-all.service'; +import { CreateUserService } from './create-user.service'; +import { DeleteUserService } from './delete-user.service'; +import { FindOneByGoogleIdProvider } from './find-one-by-googleId'; +import { CreateGoogleUserProvider } from './googleUserProvider'; +import { UpdateUserService } from './update-user.service'; +import { User } from '../user.entity'; +import { CreateUserDto } from '../dtos/createUserDto'; +import { EditUserDto } from '../dtos/editUserDto.dto'; +import { BadRequestException } from '@nestjs/common'; + +function makeUser(overrides?: Partial): User { + return { + id: 'user-1', + email: 'player@example.com', + username: 'player', + fullname: 'Player One', + xp: 0, + level: 1, + ...overrides, + } as User; +} + +describe('UsersService', () => { + let service: UsersService; + let userRepo: jest.Mocked>; + let createUserService: { execute: jest.Mock }; + let findOneByEmail: { findOneByEmail: jest.Mock }; + let findOneByWallet: { findOneByWallet: jest.Mock }; + let findAll: { findAll: jest.Mock }; + let deleteUserService: { execute: jest.Mock }; + let findOneByGoogleIdProvider: { findOneByGoogleId: jest.Mock }; + let createGoogleUserProvider: { createGoogleUser: jest.Mock }; + + beforeEach(async () => { + createUserService = { execute: jest.fn() }; + findOneByEmail = { findOneByEmail: jest.fn() }; + findOneByWallet = { findOneByWallet: jest.fn() }; + findAll = { findAll: jest.fn() }; + deleteUserService = { execute: jest.fn() }; + findOneByGoogleIdProvider = { findOneByGoogleId: jest.fn() }; + createGoogleUserProvider = { createGoogleUser: jest.fn() }; + + const mockUserRepo = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + // Real UpdateUserService so findOneById matches the JWT middleware contract. + UpdateUserService, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: FindOneByEmail, useValue: findOneByEmail }, + { provide: FindOneByWallet, useValue: findOneByWallet }, + { provide: FindAll, useValue: findAll }, + { provide: CreateUserService, useValue: createUserService }, + { provide: DeleteUserService, useValue: deleteUserService }, + { + provide: FindOneByGoogleIdProvider, + useValue: findOneByGoogleIdProvider, + }, + { + provide: CreateGoogleUserProvider, + useValue: createGoogleUserProvider, + }, + ], + }).compile(); + + service = module.get(UsersService); + userRepo = module.get(getRepositoryToken(User)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findOneById (JWT middleware contract)', () => { + it('returns the user when the id exists', async () => { + const user = makeUser(); + userRepo.findOne.mockResolvedValue(user); + + const result = await service.findOneById('user-1'); + + expect(userRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + }); + expect(result).toBe(user); + expect(result).not.toBeNull(); + }); + + it('returns null when the user does not exist (middleware must reject the token)', async () => { + userRepo.findOne.mockResolvedValue(null); + + const result = await service.findOneById('missing'); + + expect(result).toBeNull(); + }); + }); + + describe('create — uniqueness conflicts', () => { + it('propagates uniqueness conflicts from CreateUserService', async () => { + const dto = { + email: 'taken@example.com', + password: '@Password123', + } as CreateUserDto; + createUserService.execute.mockRejectedValue( + new BadRequestException('Email already in use'), + ); + + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + await expect(service.create(dto)).rejects.toThrow('Email already in use'); + expect(createUserService.execute).toHaveBeenCalledWith(dto); + }); + + it('returns the created user on success', async () => { + const dto = { + email: 'new@example.com', + password: '@Password123', + } as CreateUserDto; + const user = makeUser({ email: dto.email }); + createUserService.execute.mockResolvedValue(user); + + await expect(service.create(dto)).resolves.toBe(user); + }); + }); + + it('delegates GetOneByEmail', async () => { + const user = makeUser(); + findOneByEmail.findOneByEmail.mockResolvedValue(user); + + await expect(service.GetOneByEmail('player@example.com')).resolves.toBe( + user, + ); + expect(findOneByEmail.findOneByEmail).toHaveBeenCalledWith( + 'player@example.com', + ); + }); + + it('delegates update and delete', async () => { + const user = makeUser({ username: 'renamed' }); + userRepo.findOne.mockResolvedValue(makeUser()); + userRepo.save.mockResolvedValue(user); + deleteUserService.execute.mockResolvedValue(undefined); + + const edited = await service.update('user-1', { + username: 'renamed', + } as EditUserDto); + expect(edited.username).toBe('renamed'); + + await service.delete('user-1'); + expect(deleteUserService.execute).toHaveBeenCalledWith('user-1'); + }); +});