seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); }); function mintConfigAdminToken(): string { $admin = AdminUser::query()->create([ 'username' => 'config_admin', 'name' => 'Config QA', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } function oddsPutPayloadFromDetail(array $items): array { return collect($items)->map(fn (array $r) => [ 'provider_code' => $r['provider_code'] ?? 'GLOBAL', 'play_code' => $r['play_code'], 'prize_scope' => $r['prize_scope'], 'dimension' => $r['dimension'] ?? null, '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('play effective catalog is public and merged', function (): void { $resp = $this->getJson('/api/v1/play/effective?currency=NPR'); $resp->assertOk()->assertJsonPath('data.currency_code', 'NPR'); $plays = $resp->json('data.plays'); expect($plays)->toBeArray()->not->toBeEmpty(); expect($plays[0])->toHaveKeys(['play_code', 'config', 'odds', 'master_enabled']); }); test('play effective catalog applies provider odds over global fallback', function (): void { $activeOdds = OddsVersion::query() ->where('status', ConfigVersionStatus::Active->value) ->firstOrFail(); $globalFirst = DB::table('odds_items') ->where('version_id', $activeOdds->id) ->where('provider_code', 'GLOBAL') ->where('play_code', 'big') ->where('prize_scope', 'first') ->first(); expect($globalFirst)->not->toBeNull(); DB::table('bet_providers')->insert([ 'code' => 'MY', 'name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 2, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('odds_items')->insert([ 'version_id' => $activeOdds->id, 'provider_code' => 'MY', 'play_code' => 'big', 'prize_scope' => 'first', 'dimension' => $globalFirst->dimension, 'odds_value' => 99_000, 'rebate_rate' => $globalFirst->rebate_rate, 'commission_rate' => $globalFirst->commission_rate, 'currency_code' => $globalFirst->currency_code, 'extra_config_json' => null, 'created_at' => now(), 'updated_at' => now(), ]); $globalPlays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); $providerPlays = collect($this->getJson('/api/v1/play/effective?currency=NPR&provider_code=MY')->assertOk()->json('data.plays')); expect($globalPlays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe((int) $globalFirst->odds_value) ->and($providerPlays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(99_000) ->and($providerPlays->firstWhere('play_code', 'big')['odds']['provider_code'])->toBe('MY'); }); test('admin play config draft publish flow', function (): void { $token = mintConfigAdminToken(); $active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail(); $create = $this->postJson('/api/v1/admin/config/play-versions', [ 'reason' => 'test draft', ], ['Authorization' => 'Bearer '.$token]); $create->assertOk(); $draftId = (int) $create->json('data.id'); expect($draftId)->toBeGreaterThan(0); expect($create->json('data.status'))->toBe(ConfigVersionStatus::Draft->value); $types = PlayType::query()->orderBy('play_code')->get(); $itemPayload = []; foreach ($types 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' => 200, '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], ['Authorization' => 'Bearer '.$token], )->assertOk()->assertJsonPath('data.items.0.min_bet_amount', 200); $this->postJson( '/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], ['Authorization' => 'Bearer '.$token], )->assertOk()->assertJsonPath('data.status', ConfigVersionStatus::Active->value); $active->refresh(); expect($active->status)->toBe(ConfigVersionStatus::Archived->value); expect(PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1); }); test('admin play config publish rejects empty draft items', function (): void { $token = mintConfigAdminToken(); $create = $this->postJson('/api/v1/admin/config/play-versions', [ 'reason' => 'empty draft', ], ['Authorization' => 'Bearer '.$token]); $create->assertOk(); $draftId = (int) $create->json('data.id'); DB::table('play_config_items')->where('version_id', $draftId)->delete(); $this->postJson( '/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], ['Authorization' => 'Bearer '.$token], )->assertStatus(422); }); test('admin play-types requires authentication', function (): void { $this->getJson('/api/v1/admin/play-types')->assertUnauthorized(); }); test('admin cannot delete active play config version', function (): void { $token = mintConfigAdminToken(); $active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail(); $this->deleteJson('/api/v1/admin/config/play-versions/'.$active->id, [], [ 'Authorization' => 'Bearer '.$token, ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::ConfigVersionCannotDeleteActive->value); }); test('admin can delete draft play config version', function (): void { $token = mintConfigAdminToken(); $create = $this->postJson('/api/v1/admin/config/play-versions', [ 'reason' => 'to delete', ], ['Authorization' => 'Bearer '.$token]); $create->assertOk(); $draftId = (int) $create->json('data.id'); $this->deleteJson('/api/v1/admin/config/play-versions/'.$draftId, [], [ 'Authorization' => 'Bearer '.$token, ]) ->assertOk() ->assertJsonPath('data.deleted', true); expect(PlayConfigVersion::query()->whereKey($draftId)->exists())->toBeFalse(); }); test('admin can delete draft odds version', function (): void { $token = mintConfigAdminToken(); $create = $this->postJson('/api/v1/admin/config/odds-versions', [ 'reason' => 'to delete', ], ['Authorization' => 'Bearer '.$token]); $create->assertOk(); $draftId = (int) $create->json('data.id'); $this->deleteJson('/api/v1/admin/config/odds-versions/'.$draftId, [], [ 'Authorization' => 'Bearer '.$token, ]) ->assertOk() ->assertJsonPath('data.deleted', true); expect(OddsVersion::query()->whereKey($draftId)->exists())->toBeFalse(); }); test('admin can delete draft risk cap version', function (): void { $token = mintConfigAdminToken(); $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', [ 'reason' => 'to delete', ], ['Authorization' => 'Bearer '.$token]); $create->assertOk(); $draftId = (int) $create->json('data.id'); $this->deleteJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, [], [ 'Authorization' => 'Bearer '.$token, ]) ->assertOk() ->assertJsonPath('data.deleted', true); expect(RiskCapVersion::query()->whereKey($draftId)->exists())->toBeFalse(); }); test('admin odds config rejects out of range rebate rate', function (): void { $token = mintConfigAdminToken(); $create = $this->postJson('/api/v1/admin/config/odds-versions', [ 'reason' => 'rate bound', ], ['Authorization' => 'Bearer '.$token]); $create->assertOk(); $draftId = (int) $create->json('data.id'); $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, [ 'Authorization' => 'Bearer '.$token, ])->assertOk()->json('data.items'); $payload = oddsPutPayloadFromDetail($detail); $payload[0]['rebate_rate'] = 1.2; $this->putJson( '/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], ['Authorization' => 'Bearer '.$token], )->assertStatus(422); });