seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); }); function acceptanceMintAdminToken(): string { $admin = AdminUser::query()->create([ 'username' => 'acceptance_admin', 'name' => 'Acceptance QA', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } function acceptanceOddsPutPayloadFromDetail(array $items): array { return collect($items)->map(fn (array $r) => [ 'play_code' => $r['play_code'], 'prize_scope' => $r['prize_scope'], 'odds_value' => (int) $r['odds_value'], 'rebate_rate' => (float) $r['rebate_rate'], 'commission_rate' => (float) $r['commission_rate'], 'currency_code' => $r['currency_code'], 'extra_config_json' => $r['extra_config_json'] ?? null, ])->all(); } test('§12.6 published play limits are visible on public effective catalog without code deploy', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $itemPayload = []; foreach (PlayType::query()->orderBy('play_code')->get() as $t) { $itemPayload[] = [ 'play_code' => $t->play_code, 'category' => $t->category, 'dimension' => $t->dimension, 'bet_mode' => $t->bet_mode, 'display_name' => $t->display_name ?? $t->play_code, 'is_enabled' => true, 'min_bet_amount' => 777, 'max_bet_amount' => 400_000_000, 'display_order' => (int) $t->sort_order, 'supports_multi_number' => (bool) $t->supports_multi_number, 'reserved_rule_json' => $t->reserved_rule_json, ]; } $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk(); $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); expect($plays->firstWhere('play_code', 'big')['config']['min_bet_amount'])->toBe(777); }); test('§12.6 published odds are visible on public effective catalog without code deploy', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'acceptance odds'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); $payload = acceptanceOddsPutPayloadFromDetail($detail); foreach ($payload as &$row) { if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { $row['odds_value'] = 333_333; } } unset($row); $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(333_333); }); test('§5 odds publish archives prior version lists history and writes audit log', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $beforeArchived = OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count(); $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'audit trail'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); $this->putJson( '/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => acceptanceOddsPutPayloadFromDetail($detail)], $auth, )->assertOk(); $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); expect(OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1); expect(OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1); $list = $this->getJson('/api/v1/admin/config/odds-versions?per_page=50', $auth)->assertOk()->json('data.items'); expect(count($list))->toBeGreaterThanOrEqual(2); expect( AuditLog::query() ->where('module_code', 'odds') ->where('action_code', 'publish') ->exists(), )->toBeTrue(); }); test('§5 existing ticket_items odds snapshot row is not mutated when new odds version publishes', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $snapshot = ['frozen_odds_first' => 250_000, 'note' => 'acceptance synthetic row']; $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'p1', 'username' => 'u1', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $draw = Draw::query()->create([ 'draw_no' => 'ACC-001', 'business_date' => now()->toDateString(), 'sequence_no' => 1, 'status' => DrawStatus::Open->value, 'start_time' => null, 'close_time' => null, 'draw_time' => null, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $now = now(); $orderId = DB::table('ticket_orders')->insertGetId([ 'order_no' => 'ORD-ACC-001', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 100, 'total_rebate_amount' => 0, 'total_actual_deduct' => 100, 'total_estimated_payout' => 0, 'status' => 'confirmed', 'submit_source' => 'h5', 'client_trace_id' => null, 'created_at' => $now, 'updated_at' => $now, ]); DB::table('ticket_items')->insert([ 'ticket_no' => 'TICK-ACC-001', 'order_id' => $orderId, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => null, 'normalized_number' => '1234', 'play_code' => 'big', 'dimension' => 2, 'digit_slot' => null, 'bet_mode' => null, 'unit_bet_amount' => 100, 'total_bet_amount' => 100, 'rebate_rate_snapshot' => 0, 'commission_rate_snapshot' => 0, 'actual_deduct_amount' => 100, 'odds_snapshot_json' => json_encode($snapshot), 'rule_snapshot_json' => null, 'combination_count' => 1, 'estimated_max_payout' => 0, 'risk_locked_amount' => 0, 'status' => 'confirmed', 'fail_reason_code' => null, 'fail_reason_text' => null, 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, 'created_at' => $now, 'updated_at' => $now, ]); $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'after ticket'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); $payload = acceptanceOddsPutPayloadFromDetail($detail); foreach ($payload as &$row) { if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { $row['odds_value'] = 9_999_999; } } unset($row); $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); $stored = DB::table('ticket_items')->where('ticket_no', 'TICK-ACC-001')->value('odds_snapshot_json'); expect(json_decode((string) $stored, true))->toBe($snapshot); $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(9_999_999); }); test('§12.6 published play config controls master_enabled on public catalog without code deploy', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance master'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $rows = $this->getJson('/api/v1/admin/config/play-versions/'.$draftId, $auth)->assertOk()->json('data.items'); $payload = collect($rows)->map(fn (array $r) => [ 'play_code' => $r['play_code'], 'category' => $r['category'], 'dimension' => $r['dimension'], 'bet_mode' => $r['bet_mode'], 'display_name' => $r['display_name'], 'is_enabled' => $r['is_enabled'], 'min_bet_amount' => (int) $r['min_bet_amount'], 'max_bet_amount' => (int) $r['max_bet_amount'], 'display_order' => (int) $r['display_order'], 'supports_multi_number' => (bool) $r['supports_multi_number'], 'reserved_rule_json' => $r['reserved_rule_json'] ?? null, 'rule_text_zh' => $r['rule_text_zh'] ?? null, 'rule_text_en' => $r['rule_text_en'] ?? null, 'rule_text_ne' => $r['rule_text_ne'] ?? null, 'extra_config_json' => $r['extra_config_json'] ?? null, ])->all(); foreach ($payload as &$row) { if ($row['play_code'] === 'big') { $row['is_enabled'] = false; } } unset($row); $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk(); $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); expect($plays->firstWhere('play_code', 'big')['master_enabled'])->toBeFalse(); }); test('§5 risk cap publish is audited and version history exists', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $beforeArchived = RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count(); $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'acceptance risk'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $rows = $this->getJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, $auth)->assertOk()->json('data.items'); $payload = collect($rows)->map(fn (array $r) => [ 'draw_id' => $r['draw_id'], 'normalized_number' => $r['normalized_number'], 'cap_amount' => (int) $r['cap_amount'], 'cap_type' => $r['cap_type'], ])->all(); $this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk(); expect(RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1); expect( AuditLog::query() ->where('module_code', 'risk_cap') ->where('action_code', 'publish') ->exists(), )->toBeTrue(); $list = $this->getJson('/api/v1/admin/config/risk-cap-versions?per_page=50', $auth)->assertOk()->json('data.items'); expect(count($list))->toBeGreaterThanOrEqual(2); }); test('§10 default risk cap template applies to unconfigured numbers', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'default risk cap'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', [ 'items' => [ [ 'draw_id' => null, 'normalized_number' => '0000', 'cap_amount' => 12_345, 'cap_type' => 'default', ], [ 'draw_id' => null, 'normalized_number' => '1234', 'cap_amount' => 777, 'cap_type' => 'per_number', ], ], ], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk(); $resolver = app(PlayCatalogResolver::class); expect($resolver->resolveCapAmount(9999, '5678'))->toBe(12_345) ->and($resolver->resolveCapAmount(9999, '1234'))->toBe(777); }); test('§5 play_config publish is audited', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'audit play'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $itemPayload = []; foreach (PlayType::query()->orderBy('play_code')->get() as $t) { $itemPayload[] = [ 'play_code' => $t->play_code, 'category' => $t->category, 'dimension' => $t->dimension, 'bet_mode' => $t->bet_mode, 'display_name' => $t->display_name ?? $t->play_code, 'is_enabled' => true, 'min_bet_amount' => 100, 'max_bet_amount' => 500_000_000, 'display_order' => (int) $t->sort_order, 'supports_multi_number' => (bool) $t->supports_multi_number, 'reserved_rule_json' => $t->reserved_rule_json, ]; } $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk(); expect( AuditLog::query() ->where('module_code', 'play_config') ->where('action_code', 'publish') ->exists(), )->toBeTrue(); }); test('play type patch toggles active config and broadcasts instantly', function (): void { Event::fake([PlayToggleBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth) ->assertOk() ->assertJsonPath('data.play_code', 'big') ->assertJsonPath('data.is_enabled', false); Event::assertDispatched( PlayToggleBroadcast::class, fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false, ); expect( AuditLog::query() ->where('module_code', 'play_config') ->where('action_code', 'toggle_active') ->where('target_id', 'big') ->exists(), )->toBeTrue(); }); test('§9 play_config publish broadcasts changed play toggles', function (): void { Event::fake([PlayToggleBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'toggle broadcast'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $itemPayload = $create->json('data.items'); foreach ($itemPayload as &$row) { if ($row['play_code'] === 'big') { $row['is_enabled'] = false; } } unset($row); $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk(); Event::assertDispatched( PlayToggleBroadcast::class, fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false, ); }); test('§9 odds publish broadcasts odds update', function (): void { Event::fake([OddsUpdateBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'odds broadcast'], $auth); $create->assertOk(); $draftId = (int) $create->json('data.id'); $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); $payload = acceptanceOddsPutPayloadFromDetail($detail); foreach ($payload as &$row) { if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { $row['odds_value'] = 444_444; } } unset($row); $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); Event::assertDispatched( OddsUpdateBroadcast::class, fn (OddsUpdateBroadcast $event): bool => $event->versionId === $draftId, ); });