| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- <?php
- namespace Tests\Unit;
- use App\Services\WorldCup\Repositories\WorldCupBetRepositoryInterface;
- use App\Services\WorldCup\WorldCupBetService;
- use Carbon\Carbon;
- use Tests\TestCase;
- class WorldCupBetServiceTest extends TestCase
- {
- public function test_low_balance_can_open_bet_panel_but_button_shows_min_bet()
- {
- $service = new WorldCupBetService();
- $state = $service->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)));
- }
- }
|