diff --git a/jest.config.js b/jest.config.js index f0c47de2..7d671eee 100644 --- a/jest.config.js +++ b/jest.config.js @@ -49,6 +49,11 @@ const customJestConfig = { transform: { '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }], }, + + // Transform node_modules that are ESM + transformIgnorePatterns: [ + 'node_modules/(?!(bson|mongoose|mongodb)/)', + ], }; module.exports = createJestConfig(customJestConfig); diff --git a/src/app/contexts/LoadingContext.jsx b/src/app/contexts/LoadingContext.jsx index 012fe0bd..7d8da08b 100644 --- a/src/app/contexts/LoadingContext.jsx +++ b/src/app/contexts/LoadingContext.jsx @@ -75,7 +75,7 @@ export function LoadingProvider({ children }) { clearTimeoutRef.current = null; } } - }, [pathname]); + }, [pathname, isLoading]); // Cleanup on unmount useEffect(() => { diff --git a/src/app/hooks/useGames.js b/src/app/hooks/useGames.js index 22fad70e..3ebd3e5a 100644 --- a/src/app/hooks/useGames.js +++ b/src/app/hooks/useGames.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; /** * Custom hook for fetching and managing games data @@ -12,11 +12,7 @@ export function useGames({ sport = 'football', status = 'upcoming' } = {}) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - fetchGames(); - }, [sport, status]); - - const fetchGames = async () => { + const fetchGames = useCallback(async () => { try { setLoading(true); setError(null); @@ -39,7 +35,11 @@ export function useGames({ sport = 'football', status = 'upcoming' } = {}) { } finally { setLoading(false); } - }; + }, [sport, status]); + + useEffect(() => { + fetchGames(); + }, [fetchGames]); return { games, loading, error, refetch: fetchGames }; } diff --git a/src/app/page.jsx b/src/app/page.jsx index 3f399258..497449b6 100644 --- a/src/app/page.jsx +++ b/src/app/page.jsx @@ -54,7 +54,7 @@ export default function Home() { if (isSignedIn) { router.replace("/dashboard"); } - }, [isSignedIn, isLoaded]); + }, [isSignedIn, isLoaded, router]); return (
diff --git a/src/app/tasks/page.jsx b/src/app/tasks/page.jsx index 49588e43..e065a828 100644 --- a/src/app/tasks/page.jsx +++ b/src/app/tasks/page.jsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import useSWR from 'swr'; import { useUser } from '@clerk/nextjs'; import { @@ -39,7 +39,7 @@ export default function TasksPage() { revalidateOnFocus: false, }); - const tasks = data?.tasks || []; + const tasks = useMemo(() => data?.tasks || [], [data?.tasks]); const summary = data?.summary || null; const streak = data?.streak || null; const loading = isLoading || !isLoaded; diff --git a/src/components/experimental/ui/MinimalBettingModal.jsx b/src/components/experimental/ui/MinimalBettingModal.jsx index 6142f72d..eab36750 100644 --- a/src/components/experimental/ui/MinimalBettingModal.jsx +++ b/src/components/experimental/ui/MinimalBettingModal.jsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { useUserContext } from '@/app/contexts/UserContext'; import { useBetValidation } from '@/app/hooks'; import MinimalModal from './MinimalModal'; @@ -68,7 +68,7 @@ export default function MinimalBettingModal({ game, isOpen, onClose, onBetPlaced }; // Handle bet placement - const handlePlaceBet = async () => { + const handlePlaceBet = useCallback(async () => { try { setLoading(true); setError(null); @@ -136,7 +136,7 @@ export default function MinimalBettingModal({ game, isOpen, onClose, onBetPlaced } finally { setLoading(false); } - }; + }, [betAmount, selectedTeam, userBiscuits, game, validate, refreshUser, onBetPlaced, onClose]); // Keyboard shortcuts useEffect(() => { @@ -181,7 +181,7 @@ export default function MinimalBettingModal({ game, isOpen, onClose, onBetPlaced document.addEventListener('keydown', handleKeyPress); return () => document.removeEventListener('keydown', handleKeyPress); - }, [isOpen, betAmount, selectedTeam, loading, userBiscuits]); + }, [isOpen, betAmount, selectedTeam, loading, userBiscuits, handlePlaceBet]); if (!game) return null; diff --git a/src/server/services/BettingService.js b/src/server/services/BettingService.js index f369d9d2..19d9ae27 100644 --- a/src/server/services/BettingService.js +++ b/src/server/services/BettingService.js @@ -59,27 +59,15 @@ class BettingService { throw new Error(`Game is ${game.status} and not available for betting`); } - // Use startTime first, fall back to gameDate (align with client-side useBetValidation) - const gameTimeValue = game.startTime || game.gameDate; - const gameDateTime = new Date(gameTimeValue); + // Validation: Check if game has started + // Use a small buffer (e.g., 5 seconds) to account for slight server time differences const now = new Date(); - - console.log('[BettingService] Date comparison debug:', { - gameId, - startTime: game.startTime, - gameDate: game.gameDate, - usedValue: gameTimeValue, - gameDateParsed: gameDateTime.toISOString(), - now: now.toISOString(), - gameTimestamp: gameDateTime.getTime(), - nowTimestamp: now.getTime(), - isGameInPast: gameDateTime < now, - differenceMs: gameDateTime.getTime() - now.getTime(), - differenceHours: ((gameDateTime.getTime() - now.getTime()) / (1000 * 60 * 60)).toFixed(2), - }); + const gameTime = new Date(game.gameDate || game.startTime); // Handle both field names + const BUFFER_MS = 5000; + const cutoffTime = new Date(now.getTime() - BUFFER_MS); - if (gameDateTime < now) { - throw new Error(`Betting is closed - game has already started (gameDate: ${gameDateTime.toISOString()}, now: ${now.toISOString()})`); + if (gameTime <= cutoffTime) { + throw new Error(`Betting is closed - game has already started (gameDate: ${gameTime.toISOString()}, now: ${now.toISOString()})`); } // 5. Calculate current odds BEFORE placing bet @@ -368,5 +356,5 @@ class BettingService { } } -// Export singleton instance -export default new BettingService(); +const bettingService = new BettingService(); +export default bettingService; diff --git a/src/server/services/GameService.js b/src/server/services/GameService.js index 41c582ac..c067f5fc 100644 --- a/src/server/services/GameService.js +++ b/src/server/services/GameService.js @@ -1,6 +1,6 @@ -import Game from '../../models/Game'; +import Game from '@server/models/Game'; import BettingService from './BettingService'; -import { calculateOdds } from '../odds-calculator'; +import { calculateOdds } from '@shared/utils/odds-calculator'; /** * GameService @@ -366,5 +366,5 @@ class GameService { } } -// Export singleton instance -export default new GameService(); +const gameService = new GameService(); +export default gameService; diff --git a/src/server/services/StatisticsService.js b/src/server/services/StatisticsService.js index 196b72da..5eef492e 100644 --- a/src/server/services/StatisticsService.js +++ b/src/server/services/StatisticsService.js @@ -311,5 +311,5 @@ class StatisticsService { } } -// Export singleton instance -export default new StatisticsService(); +const statisticsService = new StatisticsService(); +export default statisticsService; diff --git a/src/server/services/__tests__/BettingService.test.js b/src/server/services/__tests__/BettingService.test.js new file mode 100644 index 00000000..45cb2e77 --- /dev/null +++ b/src/server/services/__tests__/BettingService.test.js @@ -0,0 +1,475 @@ +import mongoose from 'mongoose'; +import BettingService from '../BettingService'; +import User from '@server/models/User'; +import Game from '@server/models/Game'; +import Bet from '@server/models/Bet'; +import { validateBet, calculateOdds } from '@shared/utils/odds-calculator'; + +// Robust Mongoose Mock +jest.mock('mongoose', () => { + const mockSchemaInstance = { + index: jest.fn(), + virtual: jest.fn(() => ({ get: jest.fn() })), + pre: jest.fn(), + post: jest.fn(), + methods: {}, + statics: {}, + }; + + const MockSchema = jest.fn(() => mockSchemaInstance); + MockSchema.Types = { ObjectId: 'ObjectId' }; + + const mockMongoose = { + startSession: jest.fn(), + Types: { + ObjectId: jest.fn((id) => id), + }, + Schema: MockSchema, + model: jest.fn(), + models: {}, + }; + + return { + __esModule: true, + ...mockMongoose, + default: mockMongoose, + }; +}); + +// Mock models +jest.mock('@server/models/User', () => ({ + __esModule: true, + default: { + findOne: jest.fn(), + }, +})); +jest.mock('@server/models/Game', () => ({ + __esModule: true, + default: { + findById: jest.fn(), + }, +})); +jest.mock('@server/models/Bet', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mock('@shared/utils/odds-calculator'); + +describe('BettingService', () => { + let mockSession; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + + // Setup model methods + User.findOne = jest.fn(); + Game.findById = jest.fn(); + Bet.find = jest.fn(); + + // Mock Mongoose session and transaction + mockSession = { + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + abortTransaction: jest.fn(), + endSession: jest.fn(), + }; + + // Setup mongoose.startSession + mongoose.startSession.mockResolvedValue(mockSession); + + // Default mock implementation for odds calculator + calculateOdds.mockReturnValue({ homeOdds: 1.9, awayOdds: 1.9 }); + validateBet.mockReturnValue({ valid: true }); + }); + + describe('placeBet', () => { + const validBetParams = { + userId: 'user123', + gameId: 'game123', + betAmount: 100, + predictedWinner: 'home', + }; + + const mockUser = { + _id: 'userId123', + clerkId: 'user123', + biscuits: 1000, + save: jest.fn(), + }; + + const mockGame = { + _id: 'game123', + status: 'scheduled', + startTime: new Date(Date.now() + 86400000), // Tomorrow + homeBets: 0, + awayBets: 0, + homeBiscuitsWagered: 0, + awayBiscuitsWagered: 0, + save: jest.fn(), + }; + + let mockBetSave; + + beforeEach(() => { + mockBetSave = jest.fn(); + // Mock Bet constructor to return an object with save method + Bet.mockImplementation((data) => { + const betInstance = { + ...data, + _id: 'bet123', + save: mockBetSave, + toObject: jest.fn().mockReturnValue({ ...data, _id: 'bet123' }), + }; + // Ensure save resolves to the instance itself + mockBetSave.mockResolvedValue(betInstance); + return betInstance; + }); + }); + + // Helper to mock query chain: User.findOne().session() + const mockFindOneSession = (result) => ({ + session: jest.fn().mockResolvedValue(result), + }); + // Helper to mock query chain: Game.findById().session() + const mockFindByIdSession = (result) => ({ + session: jest.fn().mockResolvedValue(result), + }); + + it('should successfully place a bet (Happy Path)', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + Game.findById.mockReturnValue(mockFindByIdSession(mockGame)); + + const result = await BettingService.placeBet(validBetParams); + + // Verify transaction flow + expect(mongoose.startSession).toHaveBeenCalled(); + expect(mockSession.startTransaction).toHaveBeenCalled(); + expect(mockSession.commitTransaction).toHaveBeenCalled(); + expect(mockSession.endSession).toHaveBeenCalled(); + + // Verify data updates + expect(mockUser.biscuits).toBe(900); // 1000 - 100 + expect(mockUser.save).toHaveBeenCalledWith({ session: mockSession }); + expect(mockGame.homeBets).toBe(1); + expect(mockGame.save).toHaveBeenCalledWith({ session: mockSession }); + expect(mockBetSave).toHaveBeenCalledWith({ session: mockSession }); + + // Verify result structure + expect(result).toHaveProperty('bet'); + expect(result).toHaveProperty('user'); + expect(result).toHaveProperty('game'); + }); + + it('should throw error if user not found', async () => { + User.findOne.mockReturnValue(mockFindOneSession(null)); + Game.findById.mockReturnValue(mockFindByIdSession(mockGame)); + + await expect(BettingService.placeBet(validBetParams)) + .rejects.toThrow('User not found'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + + it('should throw error if game not found', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + Game.findById.mockReturnValue(mockFindByIdSession(null)); + + await expect(BettingService.placeBet(validBetParams)) + .rejects.toThrow('Game not found'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + + it('should throw error if bet validation fails', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + Game.findById.mockReturnValue(mockFindByIdSession(mockGame)); + validateBet.mockReturnValue({ valid: false, error: 'Invalid bet' }); + + await expect(BettingService.placeBet(validBetParams)) + .rejects.toThrow('Invalid bet'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + + it('should throw error if predictedWinner is invalid', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + Game.findById.mockReturnValue(mockFindByIdSession(mockGame)); + + const invalidParams = { ...validBetParams, predictedWinner: 'draw' }; + + await expect(BettingService.placeBet(invalidParams)) + .rejects.toThrow('predictedWinner must be "home" or "away"'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + + it('should throw error if game is not scheduled', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + const liveGame = { ...mockGame, status: 'live' }; + Game.findById.mockReturnValue(mockFindByIdSession(liveGame)); + + await expect(BettingService.placeBet(validBetParams)) + .rejects.toThrow('Game is live and not available for betting'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + + it('should throw error if game has already started (outside buffer)', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + const pastGame = { + ...mockGame, + startTime: new Date(Date.now() - 6000), // Started 6s ago (outside 5s buffer) + gameDate: new Date(Date.now() - 6000) + }; + Game.findById.mockReturnValue(mockFindByIdSession(pastGame)); + + await expect(BettingService.placeBet(validBetParams)) + .rejects.toThrow(/Betting is closed - game has already started/); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + + it('should allow bet if game started recently (within 5s buffer)', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + const bufferGame = { + ...mockGame, + startTime: new Date(Date.now() - 2000), // Started 2s ago (inside 5s buffer) + gameDate: new Date(Date.now() - 2000) + }; + Game.findById.mockReturnValue(mockFindByIdSession(bufferGame)); + + // Should not throw + const result = await BettingService.placeBet(validBetParams); + + expect(result).toHaveProperty('bet'); + expect(mockSession.commitTransaction).toHaveBeenCalled(); + }); + + it('should calculate correct odds based on team selection', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + Game.findById.mockReturnValue(mockFindByIdSession(mockGame)); + calculateOdds.mockReturnValue({ homeOdds: 2.5, awayOdds: 1.5 }); + + // Test Home Bet + await BettingService.placeBet({ ...validBetParams, predictedWinner: 'home' }); + // The bet created should have homeOdds (2.5) + expect(Bet).toHaveBeenCalledWith(expect.objectContaining({ odds: 2.5 })); + + // Test Away Bet + await BettingService.placeBet({ ...validBetParams, predictedWinner: 'away' }); + expect(Bet).toHaveBeenCalledWith(expect.objectContaining({ odds: 1.5 })); + }); + + it('should handle database transaction errors', async () => { + User.findOne.mockReturnValue(mockFindOneSession(mockUser)); + Game.findById.mockReturnValue(mockFindByIdSession(mockGame)); + // Simulate save error + mockUser.save.mockRejectedValue(new Error('DB Error')); + + await expect(BettingService.placeBet(validBetParams)) + .rejects.toThrow('DB Error'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + expect(mockSession.commitTransaction).not.toHaveBeenCalled(); + }); + }); + + describe('settleBetsForGame', () => { + const gameId = 'game123'; + const winner = 'home'; + + // Helper for Bet.find().session() + const mockFindSession = (result) => ({ + session: jest.fn().mockResolvedValue(result), + }); + + it('should settle bets correctly (Happy Path)', async () => { + const mockBets = [ + { + _id: 'bet1', + predictedWinner: 'home', + potentialWin: 200, + clerkId: 'user1', + save: jest.fn() + }, + { + _id: 'bet2', + predictedWinner: 'away', + betAmount: 100, + clerkId: 'user2', + save: jest.fn() + } + ]; + + const mockUser1 = { + clerkId: 'user1', + biscuits: 1000, + save: jest.fn() + }; + const mockUser2 = { + clerkId: 'user2', + biscuits: 1000, + save: jest.fn() + }; + + Bet.find.mockReturnValue(mockFindSession(mockBets)); + User.findOne + .mockReturnValueOnce(mockFindSession(mockUser1)) // For bet1 + .mockReturnValueOnce(mockFindSession(mockUser2)); // For bet2 + + const result = await BettingService.settleBetsForGame(gameId, winner); + + // Verify stats + expect(result.settled).toBe(2); + expect(result.won).toBe(1); + expect(result.lost).toBe(1); + expect(result.totalPaidOut).toBe(200); + + // Verify User 1 (Winner) + expect(mockUser1.biscuits).toBe(1200); // 1000 + 200 + expect(mockUser1.winningBets).toBe(1); + expect(mockBets[0].status).toBe('won'); + + // Verify User 2 (Loser) + expect(mockUser2.biscuits).toBe(1000); // Unchanged + expect(mockUser2.losingBets).toBe(1); + expect(mockBets[1].status).toBe('lost'); + + expect(mockSession.commitTransaction).toHaveBeenCalled(); + }); + + it('should handle case where user is not found during settlement', async () => { + const mockBets = [ + { _id: 'bet1', predictedWinner: 'home', clerkId: 'missing_user', save: jest.fn() } + ]; + + Bet.find.mockReturnValue(mockFindSession(mockBets)); + User.findOne.mockReturnValue(mockFindSession(null)); + + await expect(BettingService.settleBetsForGame(gameId, winner)) + .rejects.toThrow('User not found for bet bet1'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + + it('should rollback transaction on error', async () => { + Bet.find.mockReturnValue({ + session: jest.fn().mockRejectedValue(new Error('DB Query Failed')) + }); + + await expect(BettingService.settleBetsForGame(gameId, winner)) + .rejects.toThrow('DB Query Failed'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + }); + + describe('refundBetsForGame', () => { + const gameId = 'gameRefund'; + const reason = 'Rain delay'; + + const mockFindSession = (result) => ({ + session: jest.fn().mockResolvedValue(result), + }); + + it('should refund all pending bets', async () => { + const mockBets = [ + { + _id: 'bet1', + betAmount: 100, + clerkId: 'user1', + save: jest.fn() + } + ]; + + const mockUser = { + clerkId: 'user1', + biscuits: 500, + save: jest.fn() + }; + + Bet.find.mockReturnValue(mockFindSession(mockBets)); + User.findOne.mockReturnValue(mockFindSession(mockUser)); + + const result = await BettingService.refundBetsForGame(gameId, reason); + + expect(result.refunded).toBe(1); + expect(result.totalRefunded).toBe(100); + expect(result.reason).toBe(reason); + + // Verify User Refund + expect(mockUser.biscuits).toBe(600); // 500 + 100 + expect(mockBets[0].status).toBe('refunded'); + expect(mockBets[0].notes).toBe(reason); + + expect(mockSession.commitTransaction).toHaveBeenCalled(); + }); + + it('should skip refund if user is not found but continue (as per logic warning)', async () => { + const mockBets = [ + { _id: 'bet1', betAmount: 100, clerkId: 'missing', save: jest.fn() } + ]; + + Bet.find.mockReturnValue(mockFindSession(mockBets)); + User.findOne.mockReturnValue(mockFindSession(null)); + + const result = await BettingService.refundBetsForGame(gameId); + + expect(result.refunded).toBe(1); + expect(result.totalRefunded).toBe(0); // Skipped + expect(mockBets[0].save).not.toHaveBeenCalled(); + }); + + it('should rollback transaction on error during refund', async () => { + Bet.find.mockReturnValue({ + session: jest.fn().mockRejectedValue(new Error('Refund DB Error')) + }); + + await expect(BettingService.refundBetsForGame(gameId)) + .rejects.toThrow('Refund DB Error'); + + expect(mockSession.abortTransaction).toHaveBeenCalled(); + }); + }); + + describe('getUserBettingSummary', () => { + it('should return user summary stats', async () => { + const mockUser = { + totalBets: 10, + winningBets: 5, + losingBets: 5, + pendingBets: 0, + totalBiscuitsWagered: 1000, + totalBiscuitsWon: 1500, + totalBiscuitsLost: 500, + }; + + const mockPendingBets = [{ _id: 'p1' }]; + const mockRecentBets = [{ _id: 'r1' }]; + + User.findOne.mockResolvedValue(mockUser); + + Bet.find.mockImplementation((query) => { + if (query.status === 'pending') { + return { + populate: jest.fn().mockReturnThis(), + sort: jest.fn().mockResolvedValue(mockPendingBets) + }; + } else { + return { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockResolvedValue(mockRecentBets) + }; + } + }); + + const result = await BettingService.getUserBettingSummary('user1'); + + expect(result.summary.totalBets).toBe(10); + expect(result.pendingBets).toEqual(mockPendingBets); + expect(result.recentBets).toEqual(mockRecentBets); + }); + }); +}); \ No newline at end of file diff --git a/src/server/services/__tests__/GameService.test.js b/src/server/services/__tests__/GameService.test.js new file mode 100644 index 00000000..820b0f62 --- /dev/null +++ b/src/server/services/__tests__/GameService.test.js @@ -0,0 +1,376 @@ +import GameService from '../GameService'; +import Game from '@server/models/Game'; +import BettingService from '../BettingService'; +import { calculateOdds } from '@shared/utils/odds-calculator'; + +// Mock dependencies +jest.mock('@server/models/Game', () => { + // Create a mock class that includes static methods + const MockGame = jest.fn().mockImplementation((data) => ({ + ...data, + save: jest.fn(), + })); + + // Add static methods to the mock class + MockGame.findOne = jest.fn(); + MockGame.findById = jest.fn(); + MockGame.find = jest.fn(); + MockGame.countDocuments = jest.fn(); + MockGame.aggregate = jest.fn(); + + return { + __esModule: true, + default: MockGame, + }; +}); + +jest.mock('../BettingService', () => ({ + __esModule: true, + default: { + refundBetsForGame: jest.fn(), + settleBetsForGame: jest.fn(), + closeGameBetting: jest.fn(), // If called internally or not needed + }, +})); + +jest.mock('@shared/utils/odds-calculator', () => ({ + calculateOdds: jest.fn(), +})); + +describe('GameService', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default mock implementation that handles potential zero inputs gracefully + // mimicking a safe "initial" state if inputs are zero + calculateOdds.mockImplementation((h, a) => { + if (h === 0 && a === 0) return { homeOdds: 1.9, awayOdds: 1.9 }; // Default/Start + return { homeOdds: 1.9, awayOdds: 1.9 }; // Fixed for other tests unless overridden + }); + }); + + describe('updateGameFromESPN', () => { + const espnData = { + id: 'espn123', + competitions: [{ + competitors: [ + { homeAway: 'home', team: { displayName: 'Team A' }, score: '10' }, + { homeAway: 'away', team: { displayName: 'Team B' }, score: '5' }, + ], + venue: { fullName: 'Stadium' }, + }], + status: { type: { name: 'STATUS_SCHEDULED' } }, + date: new Date().toISOString(), + links: [{ href: 'http://espn.com' }], + }; + + it('should create a new game if it does not exist', async () => { + Game.findOne.mockResolvedValue(null); + + await GameService.updateGameFromESPN(espnData); + + expect(Game.findOne).toHaveBeenCalledWith({ apiGameId: 'espn123' }); + expect(Game).toHaveBeenCalled(); // Checks constructor call + expect(calculateOdds).toHaveBeenCalled(); + }); + + it('should update an existing game', async () => { + const existingGame = { + apiGameId: 'espn123', + status: 'scheduled', + save: jest.fn(), + homeScore: 0, + awayScore: 0, + winner: null, + }; + Game.findOne.mockResolvedValue(existingGame); + + await GameService.updateGameFromESPN(espnData); + + expect(existingGame.save).toHaveBeenCalled(); + expect(existingGame.homeTeam).toBe('Team A'); + }); + + it('should finalize game if status changes to completed', async () => { + const completedEspnData = { + ...espnData, + status: { type: { name: 'STATUS_FINAL' } }, + competitions: [{ + competitors: [ + { homeAway: 'home', team: { displayName: 'Team A' }, score: '20' }, + { homeAway: 'away', team: { displayName: 'Team B' }, score: '10' }, + ], + }] + }; + + const existingGame = { + _id: 'game123', + apiGameId: 'espn123', + status: 'live', // Was live, now completed + save: jest.fn(), + winner: null, + homeScore: 10, + awayScore: 10, + toObject: jest.fn(), + }; + + Game.findOne.mockResolvedValue(existingGame); + Game.findById.mockResolvedValue(existingGame); // For finalizeGame call + + await GameService.updateGameFromESPN(completedEspnData); + + expect(BettingService.settleBetsForGame).toHaveBeenCalled(); + expect(existingGame.save).toHaveBeenCalled(); + }); + + it('should throw error if update fails', async () => { + Game.findOne.mockRejectedValue(new Error('DB connection failed')); + + await expect(GameService.updateGameFromESPN(espnData)) + .rejects.toThrow('DB connection failed'); + }); + }); + + describe('closeGameBetting', () => { + it('should close betting for a game', async () => { + const mockGame = { _id: 'g1', bettingOpen: true, save: jest.fn(), homeTeam: 'H', awayTeam: 'A' }; + Game.findById.mockResolvedValue(mockGame); + + const result = await GameService.closeGameBetting('g1'); + + expect(mockGame.bettingOpen).toBe(false); + expect(mockGame.save).toHaveBeenCalled(); + expect(result).toBe(mockGame); + }); + + it('should throw if game not found', async () => { + Game.findById.mockResolvedValue(null); + await expect(GameService.closeGameBetting('g1')).rejects.toThrow('Game not found'); + }); + + it('should do nothing if betting is already closed', async () => { + const mockGame = { _id: 'g1', bettingOpen: false, save: jest.fn(), homeTeam: 'H', awayTeam: 'A' }; + Game.findById.mockResolvedValue(mockGame); + + await GameService.closeGameBetting('g1'); + expect(mockGame.save).not.toHaveBeenCalled(); + }); + }); + + describe('finalizeGame', () => { + it('should finalize game (Home Win)', async () => { + const mockGame = { + _id: 'g1', + status: 'live', + save: jest.fn(), + toObject: jest.fn().mockReturnValue({}), + homeTeam: 'H', + awayTeam: 'A' + }; + Game.findById.mockResolvedValue(mockGame); + BettingService.settleBetsForGame.mockResolvedValue({ settled: 10 }); + + const result = await GameService.finalizeGame('g1', 20, 10); + + expect(mockGame.status).toBe('completed'); + expect(mockGame.winner).toBe('home'); + expect(mockGame.homeScore).toBe(20); + expect(mockGame.save).toHaveBeenCalled(); + expect(BettingService.settleBetsForGame).toHaveBeenCalledWith('g1', 'home'); + expect(result.settlement).toEqual({ settled: 10 }); + }); + + it('should finalize game (Tie)', async () => { + const mockGame = { + _id: 'g1', + status: 'live', + save: jest.fn(), + toObject: jest.fn().mockReturnValue({}), + homeTeam: 'H', + awayTeam: 'A' + }; + Game.findById.mockResolvedValue(mockGame); + BettingService.refundBetsForGame.mockResolvedValue({ refunded: 5 }); + + await GameService.finalizeGame('g1', 10, 10); + + expect(mockGame.winner).toBe('tie'); + expect(BettingService.refundBetsForGame).toHaveBeenCalledWith('g1', 'Game ended in a tie'); + expect(BettingService.settleBetsForGame).not.toHaveBeenCalled(); + }); + + it('should throw if game not found', async () => { + Game.findById.mockResolvedValue(null); + await expect(GameService.finalizeGame('g1', 10, 5)).rejects.toThrow('Game not found'); + }); + }); + + describe('cancelGame', () => { + it('should cancel game and refund bets', async () => { + const mockGame = { + _id: 'g1', + status: 'scheduled', + save: jest.fn(), + toObject: jest.fn().mockReturnValue({}), + homeTeam: 'H', + awayTeam: 'A' + }; + Game.findById.mockResolvedValue(mockGame); + BettingService.refundBetsForGame.mockResolvedValue({ refunded: 10 }); + + await GameService.cancelGame('g1', 'Rain'); + + expect(mockGame.status).toBe('cancelled'); + expect(mockGame.bettingOpen).toBe(false); + expect(BettingService.refundBetsForGame).toHaveBeenCalledWith('g1', 'Rain'); + }); + + it('should throw if game not found', async () => { + Game.findById.mockResolvedValue(null); + await expect(GameService.cancelGame('g1')).rejects.toThrow('Game not found'); + }); + }); + + describe('postponeGame', () => { + it('should postpone game', async () => { + const mockGame = { _id: 'g1', status: 'scheduled', save: jest.fn(), homeTeam: 'H', awayTeam: 'A' }; + Game.findById.mockResolvedValue(mockGame); + + const newDate = new Date(); + await GameService.postponeGame('g1', newDate); + + expect(mockGame.status).toBe('postponed'); + expect(mockGame.bettingOpen).toBe(false); + expect(mockGame.gameDate).toEqual(newDate); + expect(mockGame.save).toHaveBeenCalled(); + }); + }); + + describe('recalculateGameOdds', () => { + it('should recalculate odds', async () => { + const mockGame = { + _id: 'g1', + homeBets: 10, + awayBets: 5, + homeBiscuitsWagered: 1000, + awayBiscuitsWagered: 500, + save: jest.fn(), + toObject: jest.fn().mockReturnValue({}), + homeTeam: 'H', + awayTeam: 'A' + }; + Game.findById.mockResolvedValue(mockGame); + calculateOdds.mockReturnValue({ homeOdds: 1.5, awayOdds: 2.5 }); + + await GameService.recalculateGameOdds('g1'); + + expect(calculateOdds).toHaveBeenCalledWith(10, 5, 1000, 500); + expect(mockGame.homeOdds).toBe(1.5); + expect(mockGame.awayOdds).toBe(2.5); + expect(mockGame.save).toHaveBeenCalled(); + }); + }); + + describe('getGamesByStatus', () => { + it('should return games filtered by status', async () => { + const mockChain = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(['game1']), + }; + Game.find.mockReturnValue(mockChain); + + const result = await GameService.getGamesByStatus('scheduled'); + + expect(Game.find).toHaveBeenCalledWith({ status: 'scheduled' }); + expect(result).toEqual(['game1']); + }); + + it('should apply sport filter if provided', async () => { + const mockChain = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + }; + Game.find.mockReturnValue(mockChain); + + await GameService.getGamesByStatus('scheduled', 'basketball'); + + expect(Game.find).toHaveBeenCalledWith({ status: 'scheduled', sport: 'basketball' }); + }); + }); + + describe('autoCloseBetting', () => { + it('should close betting for games that have started', async () => { + const mockGames = [ + { _id: 'g1', homeTeam: 'A', awayTeam: 'B' }, + { _id: 'g2', homeTeam: 'C', awayTeam: 'D' } + ]; + + // Mock getGamesNeedingBettingClosed query + const mockFindChain = { lean: jest.fn().mockResolvedValue(mockGames) }; + Game.find.mockReturnValue(mockFindChain); + + // Mock closeGameBetting logic via finding game by id individually + const mockGameInstance = { + _id: 'g1', + bettingOpen: true, + save: jest.fn(), + homeTeam: 'A', + awayTeam: 'B' + }; + Game.findById.mockResolvedValue(mockGameInstance); + + const result = await GameService.autoCloseBetting(); + + expect(result.closed).toBe(2); + expect(Game.findById).toHaveBeenCalledTimes(2); + }); + + it('should handle errors for individual games', async () => { + // Mock getGamesNeedingBettingClosed query + Game.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([{ _id: 'g1' }]) }); + + // Mock failure + Game.findById.mockRejectedValue(new Error('Update failed')); + + const result = await GameService.autoCloseBetting(); + + expect(result.closed).toBe(0); + }); + }); + + describe('autoFinalizeGames', () => { + it('should finalize completed games', async () => { + const mockGames = [{ _id: 'g1', homeScore: 10, awayScore: 5 }]; + // Mock getGamesNeedingFinalization + Game.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(mockGames) }); + + // Mock finalizeGame dependencies + const mockGameInstance = { + _id: 'g1', + status: 'completed', + save: jest.fn(), + toObject: jest.fn(), + homeTeam: 'H', + awayTeam: 'A' + }; + Game.findById.mockResolvedValue(mockGameInstance); + BettingService.settleBetsForGame.mockResolvedValue({}); + + const result = await GameService.autoFinalizeGames(); + + expect(result.finalized).toBe(1); + }); + + it('should track errors', async () => { + const mockGames = [{ _id: 'g1' }]; + Game.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(mockGames) }); + Game.findById.mockRejectedValue(new Error('Fail')); + + const result = await GameService.autoFinalizeGames(); + + expect(result.finalized).toBe(0); + expect(result.errors).toHaveLength(1); + }); + }); +}); \ No newline at end of file diff --git a/src/server/services/__tests__/StatisticsService.test.js b/src/server/services/__tests__/StatisticsService.test.js new file mode 100644 index 00000000..0c7fa138 --- /dev/null +++ b/src/server/services/__tests__/StatisticsService.test.js @@ -0,0 +1,265 @@ +import StatisticsService from '../StatisticsService'; +import User from '@server/models/User'; +import Bet from '@server/models/Bet'; +import { calculateWinRate, calculateROI } from '@shared/utils/stats-utils'; + +// Mock dependencies +jest.mock('@server/models/User', () => ({ + __esModule: true, + default: { + findOne: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), + aggregate: jest.fn(), + }, +})); + +jest.mock('@server/models/Bet', () => ({ + __esModule: true, + default: { + find: jest.fn(), + }, +})); + +jest.mock('@shared/utils/stats-utils', () => ({ + calculateWinRate: jest.fn(), + calculateROI: jest.fn(), +})); + +describe('StatisticsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + calculateWinRate.mockReturnValue(50); + calculateROI.mockReturnValue(10); + }); + + describe('getUserStats', () => { + const mockUser = { + clerkId: 'u1', + username: 'User1', + biscuits: 1000, + totalBets: 10, + winningBets: 5, + totalBiscuitsWagered: 100, + totalBiscuitsWon: 150, + totalBiscuitsLost: 50, + }; + + it('should return complete user stats', async () => { + User.findOne.mockResolvedValue(mockUser); + + const mockRecentBets = [{ _id: 'b1', gameId: 'g1' }]; + const mockPendingBets = [{ _id: 'b2', gameId: 'g2' }]; + + // We need to differentiate the two Bet.find calls + Bet.find + .mockReturnValueOnce({ // recent bets + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockRecentBets) + }) + .mockReturnValueOnce({ // pending bets + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockPendingBets) + }); + + const result = await StatisticsService.getUserStats('u1'); + + expect(User.findOne).toHaveBeenCalledWith({ clerkId: 'u1' }); + expect(result.user.username).toBe('User1'); + expect(result.stats.winRate).toBe(50); + expect(result.stats.roi).toBe(10); + expect(result.recentBets).toHaveLength(1); + expect(result.pendingBets).toHaveLength(1); + }); + + it('should throw if user not found', async () => { + User.findOne.mockResolvedValue(null); + await expect(StatisticsService.getUserStats('u1')).rejects.toThrow('User not found'); + }); + }); + + describe('getLeaderboard', () => { + const mockUsers = [ + { clerkId: 'u1', biscuits: 200, winningBets: 10, totalBets: 20 }, + { clerkId: 'u2', biscuits: 100, winningBets: 5, totalBets: 20 } + ]; + + it('should return paginated leaderboard sorted by biscuits', async () => { + User.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockUsers) + }); + User.countDocuments.mockResolvedValue(2); + + const result = await StatisticsService.getLeaderboard({ page: 1, limit: 10 }); + + expect(result.leaderboard).toHaveLength(2); + expect(result.leaderboard[0].rank).toBe(1); + expect(result.leaderboard[0].clerkId).toBe('u1'); + expect(result.pagination.total).toBe(2); + }); + + it('should sort by winRate manually', async () => { + // u1 has higher winRate + calculateWinRate + .mockReturnValueOnce(20) // u1 + .mockReturnValueOnce(80); // u2 + + User.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockUsers) + }); + User.countDocuments.mockResolvedValue(2); + + const result = await StatisticsService.getLeaderboard({ sortBy: 'winRate' }); + + expect(result.leaderboard[0].clerkId).toBe('u2'); + expect(result.leaderboard[1].clerkId).toBe('u1'); + }); + + it('should sort by winRate with tie-breaker (biscuits)', async () => { + // Both users have same winRate, but u1 has more biscuits (200) than u2 (100) + calculateWinRate.mockReturnValue(50); + + User.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockUsers) + }); + User.countDocuments.mockResolvedValue(2); + + const result = await StatisticsService.getLeaderboard({ sortBy: 'winRate' }); + + // Should be sorted by biscuits descending as secondary sort + // u1 (200) > u2 (100) + expect(result.leaderboard[0].clerkId).toBe('u1'); + expect(result.leaderboard[1].clerkId).toBe('u2'); + }); + + it('should sort by roi manually', async () => { + calculateROI + .mockReturnValueOnce(10) // u1 + .mockReturnValueOnce(50); // u2 + + User.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockUsers) + }); + User.countDocuments.mockResolvedValue(2); + + const result = await StatisticsService.getLeaderboard({ sortBy: 'roi' }); + + expect(result.leaderboard[0].clerkId).toBe('u2'); + }); + + it('should sort by roi with tie-breaker (biscuits)', async () => { + calculateROI.mockReturnValue(25); // Equal ROI + + User.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockUsers) + }); + User.countDocuments.mockResolvedValue(2); + + const result = await StatisticsService.getLeaderboard({ sortBy: 'roi' }); + + expect(result.leaderboard[0].clerkId).toBe('u1'); + expect(result.leaderboard[1].clerkId).toBe('u2'); + }); + + it('should sort by totalBets', async () => { + // Mock different behavior for totalBets sort if needed, but it uses DB sort mostly + User.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue(mockUsers) + }); + User.countDocuments.mockResolvedValue(2); + + const result = await StatisticsService.getLeaderboard({ sortBy: 'totalBets' }); + + // Default mock behavior is to return mockUsers as is (u1 first) + // Logic inside service just sets sortField for mongo + expect(result.leaderboard).toHaveLength(2); + }); + }); + + describe('getUserRank', () => { + it('should return user rank based on biscuits', async () => { + const mockUser = { clerkId: 'u1', biscuits: 500 }; + User.findOne.mockResolvedValue(mockUser); + User.countDocuments.mockResolvedValue(4); // 4 users ahead + + const result = await StatisticsService.getUserRank('u1', 'biscuits'); + + expect(result.rank).toBe(5); // 4 + 1 + expect(User.countDocuments).toHaveBeenCalledWith(expect.objectContaining({ + biscuits: { $gt: 500 } + })); + }); + + it('should return user rank based on totalBets', async () => { + const mockUser = { clerkId: 'u1', biscuits: 500, totalBets: 10 }; + User.findOne.mockResolvedValue(mockUser); + User.countDocuments.mockResolvedValue(2); + + const result = await StatisticsService.getUserRank('u1', 'totalBets'); + + expect(result.rank).toBe(3); + // Verify complex query structure roughly + expect(User.countDocuments).toHaveBeenCalledWith(expect.objectContaining({ + $or: expect.any(Array) + })); + }); + + it('should throw if user not found', async () => { + User.findOne.mockResolvedValue(null); + await expect(StatisticsService.getUserRank('u1')).rejects.toThrow('User not found'); + }); + }); + + describe('getGlobalStats', () => { + it('should return valid global stats', async () => { + User.countDocuments + .mockResolvedValueOnce(100) // total users + .mockResolvedValueOnce(50); // active bettors + + User.aggregate.mockResolvedValue([{ + totalBets: 1000, + totalWagered: 50000, + totalWon: 45000, + totalLost: 5000, + totalBiscuits: 100000 + }]); + + const result = await StatisticsService.getGlobalStats(); + + expect(result.users.total).toBe(100); + expect(result.users.activeBettors).toBe(50); + expect(result.bets.totalPlaced).toBe(1000); + expect(result.economy.averageBiscuitsPerUser).toBe(1000); // 100000 / 100 + }); + + it('should handle empty DB gracefully', async () => { + User.countDocuments.mockResolvedValue(0); + User.aggregate.mockResolvedValue([]); + + const result = await StatisticsService.getGlobalStats(); + + expect(result.users.total).toBe(0); + expect(result.economy.averageBiscuitsPerUser).toBe(0); + }); + }); +});