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);
+ });
+ });
+});