WorldCupBetServiceTest.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. <?php
  2. namespace Tests\Unit;
  3. use App\Services\WorldCup\Repositories\WorldCupBetRepositoryInterface;
  4. use App\Services\WorldCup\WorldCupBetService;
  5. use Carbon\Carbon;
  6. use Tests\TestCase;
  7. class WorldCupBetServiceTest extends TestCase
  8. {
  9. public function test_low_balance_can_open_bet_panel_but_button_shows_min_bet()
  10. {
  11. $service = new WorldCupBetService();
  12. $state = $service->buildBetPanelState(400, false);
  13. $this->assertTrue($state['can_open']);
  14. $this->assertTrue($state['confirm_disabled']);
  15. $this->assertSame(400, $state['default_stake']);
  16. $this->assertSame('Min bet $5', $state['button_text']);
  17. }
  18. public function test_bet_rejects_when_balance_is_below_minimum_stake()
  19. {
  20. $service = new WorldCupBetService();
  21. $result = $service->validateStake(400, 400, false);
  22. $this->assertFalse($result['success']);
  23. $this->assertSame('Min bet $5', $result['message']);
  24. }
  25. public function test_bet_rejects_stake_below_minimum_even_when_balance_is_enough()
  26. {
  27. $service = new WorldCupBetService();
  28. $result = $service->validateStake(400, 10000, false);
  29. $this->assertFalse($result['success']);
  30. $this->assertSame('Min bet $5', $result['message']);
  31. }
  32. public function test_first_bet_stake_is_capped_at_ten_dollars()
  33. {
  34. $service = new WorldCupBetService();
  35. $result = $service->validateStake(1100, 10000, false);
  36. $this->assertFalse($result['success']);
  37. $this->assertSame('First bet $5–$10 · +50% bonus · whole numbers only', $result['message']);
  38. }
  39. public function test_place_bet_writes_pending_bet_deducts_balance_and_locks_odds()
  40. {
  41. $repository = new InMemoryWorldCupBetRepository();
  42. $service = new WorldCupBetService($repository);
  43. $result = $service->placeBet([
  44. 'user_id' => 1001,
  45. 'game_id' => 90000001,
  46. 'market' => '1x2',
  47. 'match_id' => 10,
  48. 'selection' => 'home',
  49. 'stake' => 1000,
  50. 'idempotency_key' => 'bet-key-1',
  51. ], Carbon::parse('2026-06-05 12:00:00'));
  52. $this->assertTrue($result['success']);
  53. $this->assertSame('created', $result['status']);
  54. $this->assertSame(9000, $repository->balances[1001]);
  55. $this->assertSame(1000, $repository->bets[0]['stake']);
  56. $this->assertSame(2.15, $repository->bets[0]['odds']);
  57. $this->assertSame(575, $repository->bets[0]['bonus_amount']);
  58. $this->assertSame(2725, $repository->bets[0]['potential_payout']);
  59. $this->assertTrue($repository->states[1001]['first_bet_used']);
  60. }
  61. public function test_place_bet_is_idempotent_by_user_and_key()
  62. {
  63. $repository = new InMemoryWorldCupBetRepository();
  64. $service = new WorldCupBetService($repository);
  65. $payload = [
  66. 'user_id' => 1001,
  67. 'game_id' => 90000001,
  68. 'market' => '1x2',
  69. 'match_id' => 10,
  70. 'selection' => 'home',
  71. 'stake' => 1000,
  72. 'idempotency_key' => 'bet-key-1',
  73. ];
  74. $first = $service->placeBet($payload, Carbon::parse('2026-06-05 12:00:00'));
  75. $second = $service->placeBet($payload, Carbon::parse('2026-06-05 12:00:00'));
  76. $this->assertSame('created', $first['status']);
  77. $this->assertSame('exists', $second['status']);
  78. $this->assertSame(9000, $repository->balances[1001]);
  79. $this->assertCount(1, $repository->bets);
  80. }
  81. public function test_place_bet_rejects_inactive_odds_and_closed_match()
  82. {
  83. $repository = new InMemoryWorldCupBetRepository();
  84. $service = new WorldCupBetService($repository);
  85. $inactiveOdds = $service->placeBet([
  86. 'user_id' => 1001,
  87. 'game_id' => 90000001,
  88. 'market' => '1x2',
  89. 'match_id' => 10,
  90. 'selection' => 'away',
  91. 'stake' => 1000,
  92. 'idempotency_key' => 'bet-key-1',
  93. ], Carbon::parse('2026-06-05 12:00:00'));
  94. $closedMatch = $service->placeBet([
  95. 'user_id' => 1001,
  96. 'game_id' => 90000001,
  97. 'market' => '1x2',
  98. 'match_id' => 11,
  99. 'selection' => 'home',
  100. 'stake' => 1000,
  101. 'idempotency_key' => 'bet-key-2',
  102. ], Carbon::parse('2026-06-05 12:00:00'));
  103. $this->assertFalse($inactiveOdds['success']);
  104. $this->assertSame('Odds are not available', $inactiveOdds['message']);
  105. $this->assertFalse($closedMatch['success']);
  106. $this->assertSame('Match is not available', $closedMatch['message']);
  107. }
  108. public function test_place_bet_rejects_match_inside_one_hour_cutoff()
  109. {
  110. $repository = new InMemoryWorldCupBetRepository();
  111. $service = new WorldCupBetService($repository);
  112. $result = $service->placeBet([
  113. 'user_id' => 1001,
  114. 'game_id' => 90000001,
  115. 'market' => '1x2',
  116. 'match_id' => 10,
  117. 'selection' => 'home',
  118. 'stake' => 1000,
  119. 'idempotency_key' => 'bet-key-cutoff',
  120. ], Carbon::parse('2026-06-05 14:00:00'));
  121. $this->assertFalse($result['success']);
  122. $this->assertSame('Match is not available', $result['message']);
  123. }
  124. public function test_bet_log_returns_stats_and_all_rows()
  125. {
  126. $repository = new InMemoryWorldCupBetRepository();
  127. $repository->seedBetLogs();
  128. $service = new WorldCupBetService($repository);
  129. $result = $service->betLog(1001, 'all', 20);
  130. $this->assertSame(2, $result['stats']['pending_count']);
  131. $this->assertSame(2600, $result['stats']['total_won']);
  132. $this->assertCount(4, $result['list']);
  133. $this->assertSame('in_play', $result['list'][0]['display_status']);
  134. $this->assertSame(106800, $result['list'][0]['display_payout']);
  135. $this->assertSame('won', $result['list'][2]['display_status']);
  136. $this->assertSame('lost', $result['list'][3]['display_status']);
  137. $this->assertSame(-500, $result['list'][3]['display_payout']);
  138. }
  139. public function test_bet_log_filters_pending_settled_and_won()
  140. {
  141. $repository = new InMemoryWorldCupBetRepository();
  142. $repository->seedBetLogs();
  143. $service = new WorldCupBetService($repository);
  144. $pending = $service->betLog(1001, 'pending', 20);
  145. $settled = $service->betLog(1001, 'settled', 20);
  146. $won = $service->betLog(1001, 'won', 20);
  147. $this->assertCount(2, $pending['list']);
  148. $this->assertCount(2, $settled['list']);
  149. $this->assertCount(1, $won['list']);
  150. $this->assertSame('won', $won['list'][0]['display_status']);
  151. }
  152. public function test_admin_bet_logs_returns_formatted_rows_for_backoffice()
  153. {
  154. $repository = new InMemoryWorldCupBetRepository();
  155. $repository->seedBetLogs();
  156. $service = new WorldCupBetService($repository);
  157. $result = $service->adminBetLogs([
  158. 'game_id' => '90000001',
  159. 'market' => 'winner',
  160. 'status' => 'pending',
  161. 'limit' => 20,
  162. ]);
  163. $this->assertCount(1, $result['list']);
  164. $this->assertSame(90000001, $result['list'][0]['game_id']);
  165. $this->assertSame(1001, $result['list'][0]['user_id']);
  166. $this->assertSame('$178.00', $result['list'][0]['stake_text']);
  167. $this->assertSame('$0.00', $result['list'][0]['bonus_text']);
  168. $this->assertSame('Pending', $result['list'][0]['status_label']);
  169. $this->assertSame('/admin/global/id_find?UserID=1001', $result['list'][0]['user_detail_url']);
  170. $lost = $service->adminBetLogs([
  171. 'game_id' => '90000003',
  172. 'status' => 'lost',
  173. 'limit' => 20,
  174. ]);
  175. $this->assertSame('20 (South Korea vs Czechia)', $lost['list'][0]['match_label']);
  176. $this->assertSame('$0.00', $lost['list'][0]['potential_payout_text']);
  177. }
  178. }
  179. class InMemoryWorldCupBetRepository implements WorldCupBetRepositoryInterface
  180. {
  181. public $balances = [1001 => 10000];
  182. public $states = [];
  183. public $bets = [];
  184. private $matches = [
  185. 10 => [
  186. 'match_id' => 10,
  187. 'kickoff_at' => '2026-06-05 15:00:00',
  188. 'status' => 'scheduled',
  189. 'result' => null,
  190. ],
  191. 11 => [
  192. 'match_id' => 11,
  193. 'kickoff_at' => '2026-06-05 12:30:00',
  194. 'status' => 'scheduled',
  195. 'result' => null,
  196. ],
  197. ];
  198. private $logMatches = [
  199. 20 => [
  200. 'match_id' => 20,
  201. 'home_team' => 'South Korea',
  202. 'away_team' => 'Czechia',
  203. 'competition' => 'World Cup',
  204. ],
  205. ];
  206. private $odds = [
  207. '1x2:10:home' => [
  208. 'odds_id' => 1,
  209. 'market' => '1x2',
  210. 'match_id' => 10,
  211. 'selection' => 'home',
  212. 'decimal_odds' => 2.15,
  213. 'is_active' => 1,
  214. ],
  215. '1x2:10:away' => [
  216. 'odds_id' => 2,
  217. 'market' => '1x2',
  218. 'match_id' => 10,
  219. 'selection' => 'away',
  220. 'decimal_odds' => 1.95,
  221. 'is_active' => 0,
  222. ],
  223. ];
  224. public function findBetByIdempotencyKey(int $userId, string $idempotencyKey): ?array
  225. {
  226. foreach ($this->bets as $bet) {
  227. if ((int)$bet['user_id'] === $userId && $bet['idempotency_key'] === $idempotencyKey) {
  228. return $bet;
  229. }
  230. }
  231. return null;
  232. }
  233. public function findMatch(int $matchId): ?array
  234. {
  235. return $this->matches[$matchId] ?? null;
  236. }
  237. public function findActiveOdds(string $market, ?int $matchId, string $selection): ?array
  238. {
  239. $key = $market . ':' . (string)$matchId . ':' . $selection;
  240. $odds = $this->odds[$key] ?? null;
  241. if (!$odds || (int)$odds['is_active'] !== 1) {
  242. return null;
  243. }
  244. return $odds;
  245. }
  246. public function getBalance(int $userId): int
  247. {
  248. return (int)($this->balances[$userId] ?? 0);
  249. }
  250. public function isFirstBetUsed(int $userId): bool
  251. {
  252. return (bool)($this->states[$userId]['first_bet_used'] ?? false);
  253. }
  254. public function createBetAndDeductBalance(array $bet, bool $markFirstBetUsed): array
  255. {
  256. $this->balances[$bet['user_id']] -= $bet['stake'];
  257. if ($markFirstBetUsed) {
  258. $this->states[$bet['user_id']]['first_bet_used'] = true;
  259. }
  260. $bet['bet_id'] = count($this->bets) + 1;
  261. $this->bets[] = $bet;
  262. return $bet;
  263. }
  264. public function seedBetLogs(): void
  265. {
  266. $this->bets = [
  267. [
  268. 'bet_id' => 1,
  269. 'user_id' => 1001,
  270. 'game_id' => 90000001,
  271. 'market' => 'winner',
  272. 'match_id' => null,
  273. 'selection' => 'France',
  274. 'stake' => 17800,
  275. 'odds' => 6.0,
  276. 'bonus_amount' => 0,
  277. 'potential_payout' => 106800,
  278. 'status' => 'pending',
  279. 'created_at' => '2026-06-06 12:00:00',
  280. ],
  281. [
  282. 'bet_id' => 2,
  283. 'user_id' => 1001,
  284. 'game_id' => 90000001,
  285. 'market' => '1x2',
  286. 'match_id' => 20,
  287. 'selection' => 'home',
  288. 'stake' => 1000,
  289. 'odds' => 2.6,
  290. 'bonus_amount' => 800,
  291. 'potential_payout' => 2600,
  292. 'status' => 'pending',
  293. 'created_at' => '2026-06-06 11:00:00',
  294. ],
  295. [
  296. 'bet_id' => 3,
  297. 'user_id' => 1001,
  298. 'game_id' => 90000002,
  299. 'market' => '1x2',
  300. 'match_id' => 20,
  301. 'selection' => 'draw',
  302. 'stake' => 1000,
  303. 'odds' => 2.6,
  304. 'bonus_amount' => 0,
  305. 'potential_payout' => 2600,
  306. 'status' => 'won',
  307. 'created_at' => '2026-06-06 10:00:00',
  308. ],
  309. [
  310. 'bet_id' => 4,
  311. 'user_id' => 1001,
  312. 'game_id' => 90000003,
  313. 'market' => '1x2',
  314. 'match_id' => 20,
  315. 'selection' => 'draw',
  316. 'stake' => 500,
  317. 'odds' => 2.6,
  318. 'bonus_amount' => 0,
  319. 'potential_payout' => 1300,
  320. 'status' => 'lost',
  321. 'created_at' => '2026-06-06 09:00:00',
  322. ],
  323. ];
  324. }
  325. public function betLogs(int $userId, string $status, int $limit): array
  326. {
  327. $rows = array_values(array_filter($this->bets, function (array $bet) use ($userId, $status) {
  328. if ((int)$bet['user_id'] !== $userId) {
  329. return false;
  330. }
  331. if ($status === 'pending') {
  332. return $bet['status'] === 'pending';
  333. }
  334. if ($status === 'settled') {
  335. return in_array($bet['status'], ['won', 'lost'], true);
  336. }
  337. if ($status === 'won') {
  338. return $bet['status'] === 'won';
  339. }
  340. return true;
  341. }));
  342. return array_slice(array_map(function (array $bet) {
  343. $match = $bet['match_id'] ? ($this->logMatches[$bet['match_id']] ?? []) : [];
  344. return array_merge($bet, [
  345. 'home_team' => $match['home_team'] ?? null,
  346. 'away_team' => $match['away_team'] ?? null,
  347. 'competition' => $match['competition'] ?? 'World Cup',
  348. ]);
  349. }, $rows), 0, $limit);
  350. }
  351. public function betLogStats(int $userId): array
  352. {
  353. $pending = 0;
  354. $totalWon = 0;
  355. foreach ($this->bets as $bet) {
  356. if ((int)$bet['user_id'] !== $userId) {
  357. continue;
  358. }
  359. if ($bet['status'] === 'pending') {
  360. $pending++;
  361. } elseif ($bet['status'] === 'won') {
  362. $totalWon += (int)$bet['potential_payout'];
  363. }
  364. }
  365. return [
  366. 'pending_count' => $pending,
  367. 'total_won' => $totalWon,
  368. ];
  369. }
  370. public function adminBetLogs(array $filters): array
  371. {
  372. $rows = $this->bets;
  373. if (!empty($filters['game_id'])) {
  374. $rows = array_filter($rows, function (array $bet) use ($filters) {
  375. return (string)$bet['game_id'] === (string)$filters['game_id'];
  376. });
  377. }
  378. if (!empty($filters['status'])) {
  379. $rows = array_filter($rows, function (array $bet) use ($filters) {
  380. return $bet['status'] === $filters['status'];
  381. });
  382. }
  383. if (!empty($filters['market'])) {
  384. $rows = array_filter($rows, function (array $bet) use ($filters) {
  385. return $bet['market'] === $filters['market'];
  386. });
  387. }
  388. return array_values(array_slice(array_map(function (array $bet) {
  389. $match = $bet['match_id'] ? ($this->logMatches[$bet['match_id']] ?? []) : [];
  390. return array_merge($bet, [
  391. 'home_team' => $match['home_team'] ?? null,
  392. 'away_team' => $match['away_team'] ?? null,
  393. 'competition' => $match['competition'] ?? 'World Cup',
  394. ]);
  395. }, $rows), 0, (int)($filters['limit'] ?? 100)));
  396. }
  397. }