artisan('lottery:admin-auth-sync')->assertExitCode(0); }); function settingsAdminToken(): string { $admin = AdminUser::query()->create([ 'username' => 'settings_editor', 'name' => 'Settings Editor', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $role = AdminRole::query()->create([ 'slug' => 'settings_role', 'name' => 'Settings Role', ]); $role->syncLegacyPermissionSlugs([ 'prd.payout.manage', 'prd.draw_result.manage', 'prd.wallet_reconcile.manage', 'prd.rebate.manage', ]); $admin->roles()->sync([ (int) $role->id => [ 'site_id' => AdminUser::defaultAdminSiteId(), 'granted_at' => now(), ], ]); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } function settingsReadOnlyToken(): string { $admin = AdminUser::query()->create([ 'username' => 'settings_readonly', 'name' => 'Settings Readonly', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $role = AdminRole::query()->create([ 'slug' => 'settings_readonly_role', 'name' => 'Settings Readonly Role', ]); $role->syncLegacyPermissionSlugs(['prd.rebate.manage']); $admin->roles()->sync([ (int) $role->id => [ 'site_id' => AdminUser::defaultAdminSiteId(), 'granted_at' => now(), ], ]); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } test('admin can batch update settings in one request', function (): void { LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); LotterySettings::put('draw.require_manual_review', true, 'draw'); $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/batch', [ 'items' => [ ['key' => 'draw.cooldown_minutes', 'value' => 20], ['key' => 'draw.require_manual_review', 'value' => false], ], ]) ->assertOk() ->assertJsonPath('data.items.0.key', 'draw.cooldown_minutes'); expect(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(20) ->and(LotterySetting::query()->where('setting_key', 'draw.require_manual_review')->value('value_json'))->toBeFalse(); }); test('admin cannot batch update env locked deployment settings', function (): void { config(['lottery.draw.interval_minutes' => 5]); LotterySettings::put('draw.interval_minutes', 5, 'draw'); $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/batch', [ 'items' => [ ['key' => 'draw.interval_minutes', 'value' => 10], ], ]) ->assertForbidden(); expect(LotterySettings::drawIntervalMinutes())->toBe(5); }); test('admin settings deployment returns read only snapshot', function (): void { config([ 'lottery.default_currency' => 'USD', 'lottery.draw.interval_minutes' => 7, ]); $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/settings/deployment') ->assertOk() ->assertJsonPath('data.items.0.key', 'currency.default_code') ->assertJsonPath('data.items.0.value', 'USD') ->assertJsonPath('data.items.0.env_var', 'LOTTERY_DEFAULT_CURRENCY') ->assertJsonFragment(['key' => 'draw.interval_minutes', 'value' => 7]); }); test('admin settings batch update rejects empty items', function (): void { $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/batch', ['items' => []]) ->assertUnprocessable(); }); test('admin can batch update settings with false and empty string values', function (): void { LotterySettings::put('settlement.auto_run_on_tick', true, 'settlement'); LotterySettings::put('frontend.play_rules_html_zh', '
old
', 'frontend'); $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/batch', [ 'items' => [ ['key' => 'settlement.auto_run_on_tick', 'value' => false], ['key' => 'frontend.play_rules_html_zh', 'value' => ''], ], ]) ->assertOk() ->assertJsonPath('data.items.0.key', 'frontend.play_rules_html_zh') ->assertJsonPath('data.items.0.value', '') ->assertJsonPath('data.items.1.key', 'settlement.auto_run_on_tick') ->assertJsonPath('data.items.1.value', false); expect(LotterySetting::query()->where('setting_key', 'settlement.auto_run_on_tick')->value('value_json'))->toBeFalse() ->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe(''); }); test('admin can update single setting with false value', function (): void { LotterySettings::put('settlement.apply_rebate_to_payout', true, 'settlement'); $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/settlement.apply_rebate_to_payout', [ 'value' => false, ]) ->assertOk() ->assertJsonPath('data.key', 'settlement.apply_rebate_to_payout') ->assertJsonPath('data.value', false); expect(LotterySetting::query()->where('setting_key', 'settlement.apply_rebate_to_payout')->value('value_json'))->toBeFalse(); }); test('non payout manager cannot batch update settlement settings', function (): void { LotterySettings::put('settlement.auto_payout_on_tick', true, 'settlement'); $token = settingsReadOnlyToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/batch', [ 'items' => [ ['key' => 'settlement.auto_payout_on_tick', 'value' => false], ], ]) ->assertForbidden(); expect(LotterySetting::query()->where('setting_key', 'settlement.auto_payout_on_tick')->value('value_json'))->toBeTrue(); }); test('non payout manager cannot update single settlement setting', function (): void { LotterySettings::put('settlement.auto_approve_on_tick', true, 'settlement'); $token = settingsReadOnlyToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/settlement.auto_approve_on_tick', [ 'value' => false, ]) ->assertForbidden(); expect(LotterySetting::query()->where('setting_key', 'settlement.auto_approve_on_tick')->value('value_json'))->toBeTrue(); }); test('wallet limits must be positive integer minor units', function (): void { LotterySettings::put('wallet.transfer_in_min_minor', 100, 'wallet'); $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/batch', [ 'items' => [ ['key' => 'wallet.transfer_in_min_minor', 'value' => 0], ], ]) ->assertUnprocessable(); expect(LotterySetting::query()->where('setting_key', 'wallet.transfer_in_min_minor')->value('value_json'))->toBe(100); }); test('wallet max limit cannot be less than current min limit', function (): void { LotterySettings::put('wallet.transfer_in_min_minor', 100, 'wallet'); LotterySettings::put('wallet.transfer_in_max_minor', 10_000, 'wallet'); $token = settingsAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/wallet.transfer_in_max_minor', [ 'value' => 50, ]) ->assertUnprocessable(); expect(LotterySetting::query()->where('setting_key', 'wallet.transfer_in_max_minor')->value('value_json'))->toBe(10_000); }); test('settings updates require permission for their setting group', function (): void { LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); LotterySettings::put('frontend.play_rules_html_zh', '
old
', 'frontend'); $token = settingsReadOnlyToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/draw.cooldown_minutes', [ 'value' => 10, ]) ->assertForbidden(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/settings/frontend.play_rules_html_zh', [ 'value' => '
new
', ]) ->assertOk(); expect(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(15) ->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('
new
'); });