buildBetPanelState(400, false); $this->assertTrue($state['can_open']); $this->assertTrue($state['confirm_disabled']); $this->assertSame(400, $state['default_stake']); $this->assertSame('Min bet $5', $state['button_text']); } public function test_bet_rejects_when_balance_is_below_minimum_stake() { $service = new WorldCupBetService(); $result = $service->validateStake(400, 400, false); $this->assertFalse($result['success']); $this->assertSame('Min bet $5', $result['message']); } public function test_bet_rejects_stake_below_minimum_even_when_balance_is_enough() { $service = new WorldCupBetService(); $result = $service->validateStake(400, 10000, false); $this->assertFalse($result['success']); $this->assertSame('Min bet $5', $result['message']); } public function test_first_bet_stake_is_capped_at_ten_dollars() { $service = new WorldCupBetService(); $result = $service->validateStake(1100, 10000, false); $this->assertFalse($result['success']); $this->assertSame('First bet $5–$10 · +50% bonus · whole numbers only', $result['message']); } public function test_place_bet_writes_pending_bet_deducts_balance_and_locks_odds() { $repository = new InMemoryWorldCupBetRepository(); $service = new WorldCupBetService($repository); $result = $service->placeBet([ 'user_id' => 1001, 'game_id' => 90000001, 'market' => '1x2', 'match_id' => 10, 'selection' => 'home', 'stake' => 1000, 'idempotency_key' => 'bet-key-1', ], Carbon::parse('2026-06-05 12:00:00')); $this->assertTrue($result['success']); $this->assertSame('created', $result['status']); $this->assertSame(9000, $repository->balances[1001]); $this->assertSame(1000, $repository->bets[0]['stake']); $this->assertSame(2.15, $repository->bets[0]['odds']); $this->assertSame(575, $repository->bets[0]['bonus_amount']); $this->assertSame(2725, $repository->bets[0]['potential_payout']); $this->assertTrue($repository->states[1001]['first_bet_used']); } public function test_place_bet_is_idempotent_by_user_and_key() { $repository = new InMemoryWorldCupBetRepository(); $service = new WorldCupBetService($repository); $payload = [ 'user_id' => 1001, 'game_id' => 90000001, 'market' => '1x2', 'match_id' => 10, 'selection' => 'home', 'stake' => 1000, 'idempotency_key' => 'bet-key-1', ]; $first = $service->placeBet($payload, Carbon::parse('2026-06-05 12:00:00')); $second = $service->placeBet($payload, Carbon::parse('2026-06-05 12:00:00')); $this->assertSame('created', $first['status']); $this->assertSame('exists', $second['status']); $this->assertSame(9000, $repository->balances[1001]); $this->assertCount(1, $repository->bets); } public function test_place_bet_rejects_inactive_odds_and_closed_match() { $repository = new InMemoryWorldCupBetRepository(); $service = new WorldCupBetService($repository); $inactiveOdds = $service->placeBet([ 'user_id' => 1001, 'game_id' => 90000001, 'market' => '1x2', 'match_id' => 10, 'selection' => 'away', 'stake' => 1000, 'idempotency_key' => 'bet-key-1', ], Carbon::parse('2026-06-05 12:00:00')); $closedMatch = $service->placeBet([ 'user_id' => 1001, 'game_id' => 90000001, 'market' => '1x2', 'match_id' => 11, 'selection' => 'home', 'stake' => 1000, 'idempotency_key' => 'bet-key-2', ], Carbon::parse('2026-06-05 12:00:00')); $this->assertFalse($inactiveOdds['success']); $this->assertSame('Odds are not available', $inactiveOdds['message']); $this->assertFalse($closedMatch['success']); $this->assertSame('Match is not available', $closedMatch['message']); } public function test_place_bet_rejects_match_inside_one_hour_cutoff() { $repository = new InMemoryWorldCupBetRepository(); $service = new WorldCupBetService($repository); $result = $service->placeBet([ 'user_id' => 1001, 'game_id' => 90000001, 'market' => '1x2', 'match_id' => 10, 'selection' => 'home', 'stake' => 1000, 'idempotency_key' => 'bet-key-cutoff', ], Carbon::parse('2026-06-05 14:00:00')); $this->assertFalse($result['success']); $this->assertSame('Match is not available', $result['message']); } public function test_bet_log_returns_stats_and_all_rows() { $repository = new InMemoryWorldCupBetRepository(); $repository->seedBetLogs(); $service = new WorldCupBetService($repository); $result = $service->betLog(1001, 'all', 20); $this->assertSame(2, $result['stats']['pending_count']); $this->assertSame(2600, $result['stats']['total_won']); $this->assertCount(4, $result['list']); $this->assertSame('in_play', $result['list'][0]['display_status']); $this->assertSame(106800, $result['list'][0]['display_payout']); $this->assertSame('won', $result['list'][2]['display_status']); $this->assertSame('lost', $result['list'][3]['display_status']); $this->assertSame(-500, $result['list'][3]['display_payout']); } public function test_bet_log_filters_pending_settled_and_won() { $repository = new InMemoryWorldCupBetRepository(); $repository->seedBetLogs(); $service = new WorldCupBetService($repository); $pending = $service->betLog(1001, 'pending', 20); $settled = $service->betLog(1001, 'settled', 20); $won = $service->betLog(1001, 'won', 20); $this->assertCount(2, $pending['list']); $this->assertCount(2, $settled['list']); $this->assertCount(1, $won['list']); $this->assertSame('won', $won['list'][0]['display_status']); } public function test_admin_bet_logs_returns_formatted_rows_for_backoffice() { $repository = new InMemoryWorldCupBetRepository(); $repository->seedBetLogs(); $service = new WorldCupBetService($repository); $result = $service->adminBetLogs([ 'game_id' => '90000001', 'market' => 'winner', 'status' => 'pending', 'limit' => 20, ]); $this->assertCount(1, $result['list']); $this->assertSame(90000001, $result['list'][0]['game_id']); $this->assertSame(1001, $result['list'][0]['user_id']); $this->assertSame('$178.00', $result['list'][0]['stake_text']); $this->assertSame('$0.00', $result['list'][0]['bonus_text']); $this->assertSame('Pending', $result['list'][0]['status_label']); $this->assertSame('/admin/global/id_find?UserID=1001', $result['list'][0]['user_detail_url']); $lost = $service->adminBetLogs([ 'game_id' => '90000003', 'status' => 'lost', 'limit' => 20, ]); $this->assertSame('20 (South Korea vs Czechia)', $lost['list'][0]['match_label']); $this->assertSame('$0.00', $lost['list'][0]['potential_payout_text']); } } class InMemoryWorldCupBetRepository implements WorldCupBetRepositoryInterface { public $balances = [1001 => 10000]; public $states = []; public $bets = []; private $matches = [ 10 => [ 'match_id' => 10, 'kickoff_at' => '2026-06-05 15:00:00', 'status' => 'scheduled', 'result' => null, ], 11 => [ 'match_id' => 11, 'kickoff_at' => '2026-06-05 12:30:00', 'status' => 'scheduled', 'result' => null, ], ]; private $logMatches = [ 20 => [ 'match_id' => 20, 'home_team' => 'South Korea', 'away_team' => 'Czechia', 'competition' => 'World Cup', ], ]; private $odds = [ '1x2:10:home' => [ 'odds_id' => 1, 'market' => '1x2', 'match_id' => 10, 'selection' => 'home', 'decimal_odds' => 2.15, 'is_active' => 1, ], '1x2:10:away' => [ 'odds_id' => 2, 'market' => '1x2', 'match_id' => 10, 'selection' => 'away', 'decimal_odds' => 1.95, 'is_active' => 0, ], ]; public function findBetByIdempotencyKey(int $userId, string $idempotencyKey): ?array { foreach ($this->bets as $bet) { if ((int)$bet['user_id'] === $userId && $bet['idempotency_key'] === $idempotencyKey) { return $bet; } } return null; } public function findMatch(int $matchId): ?array { return $this->matches[$matchId] ?? null; } public function findActiveOdds(string $market, ?int $matchId, string $selection): ?array { $key = $market . ':' . (string)$matchId . ':' . $selection; $odds = $this->odds[$key] ?? null; if (!$odds || (int)$odds['is_active'] !== 1) { return null; } return $odds; } public function getBalance(int $userId): int { return (int)($this->balances[$userId] ?? 0); } public function isFirstBetUsed(int $userId): bool { return (bool)($this->states[$userId]['first_bet_used'] ?? false); } public function createBetAndDeductBalance(array $bet, bool $markFirstBetUsed): array { $this->balances[$bet['user_id']] -= $bet['stake']; if ($markFirstBetUsed) { $this->states[$bet['user_id']]['first_bet_used'] = true; } $bet['bet_id'] = count($this->bets) + 1; $this->bets[] = $bet; return $bet; } public function seedBetLogs(): void { $this->bets = [ [ 'bet_id' => 1, 'user_id' => 1001, 'game_id' => 90000001, 'market' => 'winner', 'match_id' => null, 'selection' => 'France', 'stake' => 17800, 'odds' => 6.0, 'bonus_amount' => 0, 'potential_payout' => 106800, 'status' => 'pending', 'created_at' => '2026-06-06 12:00:00', ], [ 'bet_id' => 2, 'user_id' => 1001, 'game_id' => 90000001, 'market' => '1x2', 'match_id' => 20, 'selection' => 'home', 'stake' => 1000, 'odds' => 2.6, 'bonus_amount' => 800, 'potential_payout' => 2600, 'status' => 'pending', 'created_at' => '2026-06-06 11:00:00', ], [ 'bet_id' => 3, 'user_id' => 1001, 'game_id' => 90000002, 'market' => '1x2', 'match_id' => 20, 'selection' => 'draw', 'stake' => 1000, 'odds' => 2.6, 'bonus_amount' => 0, 'potential_payout' => 2600, 'status' => 'won', 'created_at' => '2026-06-06 10:00:00', ], [ 'bet_id' => 4, 'user_id' => 1001, 'game_id' => 90000003, 'market' => '1x2', 'match_id' => 20, 'selection' => 'draw', 'stake' => 500, 'odds' => 2.6, 'bonus_amount' => 0, 'potential_payout' => 1300, 'status' => 'lost', 'created_at' => '2026-06-06 09:00:00', ], ]; } public function betLogs(int $userId, string $status, int $limit): array { $rows = array_values(array_filter($this->bets, function (array $bet) use ($userId, $status) { if ((int)$bet['user_id'] !== $userId) { return false; } if ($status === 'pending') { return $bet['status'] === 'pending'; } if ($status === 'settled') { return in_array($bet['status'], ['won', 'lost'], true); } if ($status === 'won') { return $bet['status'] === 'won'; } return true; })); return array_slice(array_map(function (array $bet) { $match = $bet['match_id'] ? ($this->logMatches[$bet['match_id']] ?? []) : []; return array_merge($bet, [ 'home_team' => $match['home_team'] ?? null, 'away_team' => $match['away_team'] ?? null, 'competition' => $match['competition'] ?? 'World Cup', ]); }, $rows), 0, $limit); } public function betLogStats(int $userId): array { $pending = 0; $totalWon = 0; foreach ($this->bets as $bet) { if ((int)$bet['user_id'] !== $userId) { continue; } if ($bet['status'] === 'pending') { $pending++; } elseif ($bet['status'] === 'won') { $totalWon += (int)$bet['potential_payout']; } } return [ 'pending_count' => $pending, 'total_won' => $totalWon, ]; } public function adminBetLogs(array $filters): array { $rows = $this->bets; if (!empty($filters['game_id'])) { $rows = array_filter($rows, function (array $bet) use ($filters) { return (string)$bet['game_id'] === (string)$filters['game_id']; }); } if (!empty($filters['status'])) { $rows = array_filter($rows, function (array $bet) use ($filters) { return $bet['status'] === $filters['status']; }); } if (!empty($filters['market'])) { $rows = array_filter($rows, function (array $bet) use ($filters) { return $bet['market'] === $filters['market']; }); } return array_values(array_slice(array_map(function (array $bet) { $match = $bet['match_id'] ? ($this->logMatches[$bet['match_id']] ?? []) : []; return array_merge($bet, [ 'home_team' => $match['home_team'] ?? null, 'away_team' => $match['away_team'] ?? null, 'competition' => $match['competition'] ?? 'World Cup', ]); }, $rows), 0, (int)($filters['limit'] ?? 100))); } }