'reverb']); config(['lottery.main_site.wallet_api_url' => null]); $this->seed(CurrencySeeder::class); $this->seed(LotterySettingsSeeder::class); }); test('balance update broadcasts only on the player private channel', function (): void { $event = new BalanceUpdateBroadcast(42, 'NPR', 10_000, -500, 'bet', 1_234_567); $channels = $event->broadcastOn(); expect($channels)->toHaveCount(1) ->and($channels[0])->toBeInstanceOf(PrivateChannel::class) ->and($channels[0]->name)->toBe('private-player.42'); }); test('session replacement broadcasts the monotonic version on the player private channel', function (): void { $event = new PlayerSessionReplacedBroadcast(42, 3, 1_234_567); $channels = $event->broadcastOn(); expect($channels)->toHaveCount(1) ->and($channels[0])->toBeInstanceOf(PrivateChannel::class) ->and($channels[0]->name)->toBe('private-player.42') ->and($event->broadcastAs())->toBe('session.replaced') ->and($event->broadcastWith())->toBe([ 'player_id' => 42, 'session_version' => 3, 'emitted_at_ms' => 1_234_567, ]); }); test('player private channel auth allows only the matching bearer player', function (): void { config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.key' => 'test-reverb-key', 'broadcasting.connections.reverb.secret' => 'test-reverb-secret', 'broadcasting.connections.reverb.app_id' => 'test-reverb-app', ]); Broadcast::purge('reverb'); require base_path('routes/channels.php'); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'ws-auth-owner', 'default_currency' => 'NPR', 'status' => 0, ]); $otherPlayer = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'ws-auth-other', 'default_currency' => 'NPR', 'status' => 0, ]); $payload = [ 'socket_id' => '1234.5678', 'channel_name' => 'private-player.'.$player->id, ]; $this->postJson('/api/broadcasting/auth', $payload) ->assertUnauthorized(); $this->withHeader('Authorization', 'Bearer dev:'.$otherPlayer->id) ->postJson('/api/broadcasting/auth', $payload) ->assertForbidden(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/broadcasting/auth', $payload) ->assertOk() ->assertJsonStructure(['auth']); }); test('wallet balance notifier dispatches balance update broadcast', function (): void { Event::fake([BalanceUpdateBroadcast::class]); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'ws-p1', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 10_000, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); app(WalletBalanceRealtimeNotifier::class)->notifyAfterMovement($wallet, -500, 'bet_deduct'); Event::assertDispatched( BalanceUpdateBroadcast::class, fn (BalanceUpdateBroadcast $event): bool => $event->playerId === $player->id && $event->currencyCode === 'NPR' && $event->balanceMinor === 10_000 && $event->changeMinor === -500 && $event->reason === 'bet', ); }); test('risk pool acquire dispatches warning and sold out broadcasts', function (): void { Event::fake([RiskWarningBroadcast::class, RiskSoldOutBroadcast::class]); $draw = Draw::query()->create([ 'draw_no' => '20260526-001', 'business_date' => '2026-05-26', 'sequence_no' => 1, 'status' => 'open', 'start_time' => now()->subHour(), 'close_time' => now()->addHour(), 'draw_time' => now()->addHours(2), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 1000, 'locked_amount' => 750, 'remaining_amount' => 250, 'sold_out_status' => 0, 'version' => 0, ]); app(RiskPoolService::class)->acquire($draw->id, 'SG', null, [ ['number_4d' => '1234', 'amount' => 250], ]); Event::assertDispatched( RiskWarningBroadcast::class, fn (RiskWarningBroadcast $event): bool => $event->drawId === $draw->id && $event->drawNo === '20260526-001' && $event->normalizedNumber === '1234', ); Event::assertDispatched( RiskSoldOutBroadcast::class, fn (RiskSoldOutBroadcast $event): bool => $event->drawId === $draw->id && $event->normalizedNumber === '1234', ); }); test('risk cap publish dispatches play catalog updated broadcast', function (): void { Event::fake([PlayCatalogUpdatedBroadcast::class]); $admin = AdminUser::query()->create([ 'username' => 'risk_cap_admin', 'name' => 'Risk Cap QA', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $draft = app(RiskCapStreamService::class)->createDraft($admin, 'test', null); app(RiskCapStreamService::class)->replaceItems($draft, [ [ 'normalized_number' => '1234', 'cap_amount' => 1_000_000, 'cap_type' => 'default', ], ], $admin); app(RiskCapStreamService::class)->publish($draft, $admin); Event::assertDispatched(PlayCatalogUpdatedBroadcast::class); }); test('transfer in dispatches balance update after success', function (): void { Event::fake([BalanceUpdateBroadcast::class]); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'ws-p2', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 0, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); app(LotteryTransferService::class)->transferIn($player, 'NPR', 500, 'idem-ws-'.uniqid('', true)); Event::assertDispatched(BalanceUpdateBroadcast::class); });