feat: enhance player authentication and agent management features
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
- Updated AGENTS.md to clarify player interface bindings and agent account restrictions. - Improved PlayerAuthLoginController to include captcha verification for player login. - Enhanced AdminPlayerIndexController with permission checks for admin users. - Refactored AdminPlayerStoreController to enforce agent node restrictions for non-super admins. - Introduced new error codes for player authentication failures and updated related services. - Enhanced validation rules for agent profiles to include settlement cycle options. - Improved AdminCaptchaService to support separate scopes for admin and player captcha handling. - Updated various services to ensure proper credit management and settlement processes.
This commit is contained in:
@@ -29,6 +29,11 @@ test('super admin can provision root agent on existing integration site', functi
|
||||
'code' => 'line-alpha',
|
||||
'name' => 'Line Alpha Site',
|
||||
'status' => 1,
|
||||
'admin_account' => [
|
||||
'username' => 'line_alpha_site_admin',
|
||||
'nickname' => 'Line Alpha Admin',
|
||||
'password' => 'secret-strong',
|
||||
],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.code', 'line-alpha');
|
||||
@@ -80,6 +85,11 @@ test('agent line provision rejects site that already has root', function (): voi
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'line-beta',
|
||||
'name' => 'Line Beta Site',
|
||||
'admin_account' => [
|
||||
'username' => 'line_beta_site_admin',
|
||||
'nickname' => 'Line Beta Admin',
|
||||
'password' => 'secret-strong',
|
||||
],
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
@@ -150,6 +160,11 @@ test('integration manager with site.manage can create integration site', functio
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'ops-site',
|
||||
'name' => 'Ops Site',
|
||||
'admin_account' => [
|
||||
'username' => 'ops_site_admin',
|
||||
'nickname' => 'Ops Admin',
|
||||
'password' => 'secret-strong',
|
||||
],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.code', 'ops-site');
|
||||
|
||||
@@ -79,7 +79,7 @@ test('admin login returns bearer token when captcha passes validation', function
|
||||
->assertJsonPath('data.admin.navigation.0.nav_group', 'overview')
|
||||
->assertJsonPath('data.admin.navigation.1.segment', 'agents')
|
||||
->assertJsonPath('data.admin.navigation.1.nav_group', 'agent')
|
||||
->assertJsonPath('data.admin.navigation.2.segment', 'draws')
|
||||
->assertJsonPath('data.admin.navigation.2.segment', 'agent_list')
|
||||
->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'operational_permissions', 'navigation']]]);
|
||||
|
||||
$token = $resp->json('data.token');
|
||||
|
||||
@@ -5,6 +5,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('admin authorization audit reports missing api resources for protected routes', function (): void {
|
||||
DB::table('admin_api_resources')
|
||||
->where('code', 'admin.config.play-versions.index')
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -12,8 +13,10 @@ uses(RefreshDatabase::class);
|
||||
|
||||
test('dashboard analytics returns summary trend and play breakdown for period', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
$siteCode = (string) DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
$businessDate = now()->subDays(3)->toDateString();
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_code' => $siteCode !== '' ? $siteCode : 'default_site',
|
||||
'site_player_id' => 'da-p1',
|
||||
'username' => 'da_u1',
|
||||
'nickname' => null,
|
||||
@@ -23,7 +26,7 @@ test('dashboard analytics returns summary trend and play breakdown for period',
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260510-001',
|
||||
'business_date' => '2026-05-10',
|
||||
'business_date' => $businessDate,
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'start_time' => now()->subDay(),
|
||||
|
||||
@@ -409,7 +409,7 @@ test('wallet_api_url rejects non-https', function (): void {
|
||||
],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。');
|
||||
->assertJsonPath('data.errors.wallet_api_url.0', fn (string $msg): bool => str_contains($msg, 'https'));
|
||||
});
|
||||
|
||||
test('wallet_api_url rejects localhost', function (): void {
|
||||
@@ -427,7 +427,7 @@ test('wallet_api_url rejects localhost', function (): void {
|
||||
],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。');
|
||||
->assertJsonPath('data.errors.wallet_api_url.0', fn (string $msg): bool => str_contains($msg, 'localhost') || str_contains($msg, 'private'));
|
||||
});
|
||||
|
||||
test('wallet_api_url rejects private ip with path', function (): void {
|
||||
@@ -445,7 +445,7 @@ test('wallet_api_url rejects private ip with path', function (): void {
|
||||
],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。');
|
||||
->assertJsonPath('data.errors.wallet_api_url.0', fn (string $msg): bool => str_contains($msg, 'https'));
|
||||
});
|
||||
|
||||
test('super admin can delete integration site and cleanup related data', function (): void {
|
||||
|
||||
@@ -17,6 +17,7 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function playerManageAdminToken(): string
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use Database\Seeders\AdminRbacAndUserSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function makeFinanceReportAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
@@ -19,7 +22,7 @@ function makeFinanceReportAdminToken(): string
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->where('slug', 'finance')->firstOrFail();
|
||||
$role = AdminRole::query()->where('slug', 'site_finance')->firstOrFail();
|
||||
$siteId = AdminUser::defaultAdminSiteId();
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => [
|
||||
@@ -32,9 +35,7 @@ function makeFinanceReportAdminToken(): string
|
||||
}
|
||||
|
||||
test('finance role with report legacy can access report jobs after rbac seed', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
|
||||
$finance = AdminRole::query()->where('slug', 'finance')->firstOrFail();
|
||||
$finance = AdminRole::query()->where('slug', 'site_finance')->firstOrFail();
|
||||
expect($finance->legacyPermissionSlugs())->toContain('prd.report.view');
|
||||
|
||||
$hasReportAction = DB::table('admin_role_menu_actions as rma')
|
||||
@@ -53,10 +54,6 @@ test('finance role with report legacy can access report jobs after rbac seed', f
|
||||
});
|
||||
|
||||
test('report read api resources bind service.report.view only', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$codes = [
|
||||
'admin.reports.daily-profit',
|
||||
'admin.report-jobs.index',
|
||||
@@ -69,10 +66,6 @@ test('report read api resources bind service.report.view only', function (): voi
|
||||
});
|
||||
|
||||
test('report export api resources bind service.report.export', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
expect(bindingsForResource('admin.report-jobs.download'))->toBe(['service.report.export']);
|
||||
expect(bindingsForResource('admin.report-jobs.store'))->toBe(['service.report.export']);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function settingsAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
@@ -23,7 +27,12 @@ function settingsAdminToken(): string
|
||||
'slug' => 'settings_role',
|
||||
'name' => 'Settings Role',
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs(['prd.payout.manage']);
|
||||
$role->syncLegacyPermissionSlugs([
|
||||
'prd.payout.manage',
|
||||
'prd.draw_result.manage',
|
||||
'prd.wallet_reconcile.manage',
|
||||
'prd.rebate.manage',
|
||||
]);
|
||||
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => [
|
||||
|
||||
@@ -249,6 +249,7 @@ test('permission catalog groups permissions by admin navigation order', function
|
||||
'jackpot',
|
||||
'risk_cap',
|
||||
'currencies',
|
||||
'integration',
|
||||
'admin_users',
|
||||
'admin_roles',
|
||||
'audit',
|
||||
|
||||
@@ -51,6 +51,8 @@ test('player credit account syncs agent allocated credit', function (): void {
|
||||
'site_code' => 'line-alloc',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-alloc-1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'alloc1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
@@ -90,6 +92,8 @@ test('win loss does not change agent allocated credit', function (): void {
|
||||
'site_code' => 'line-hold',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-hold-1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'hold1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
@@ -121,6 +125,8 @@ test('raising player credit limit succeeds when agent allocated credit includes
|
||||
'site_code' => 'line-player-raise',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-other',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'other',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
@@ -144,6 +150,8 @@ test('raising player credit limit succeeds when agent allocated credit includes
|
||||
'site_code' => 'line-player-raise',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-raise-1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'raise1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
|
||||
@@ -32,6 +32,12 @@ test('period close aggregates share ledger written by game settlement recorder',
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
AgentProfile::query()->where('agent_node_id', $rootId)->update([
|
||||
'total_share_rate' => 100,
|
||||
'default_player_rebate' => 0.005,
|
||||
'rebate_limit' => 0.01,
|
||||
]);
|
||||
|
||||
$leaf = app(AgentNodeService::class)->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'PIPE-C',
|
||||
@@ -39,14 +45,10 @@ test('period close aggregates share ledger written by game settlement recorder',
|
||||
'username' => 'pipe_c',
|
||||
'total_share_rate' => 25,
|
||||
'credit_limit' => 100_000,
|
||||
'rebate_limit' => 0.01,
|
||||
'default_player_rebate' => 0.005,
|
||||
]));
|
||||
|
||||
AgentProfile::query()->where('agent_node_id', $rootId)->update([
|
||||
'total_share_rate' => 100,
|
||||
'default_player_rebate' => 0.005,
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $leaf->id,
|
||||
@@ -185,6 +187,12 @@ test('credit settlement records base rebate plus add-on rebate and keeps extra r
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
AgentProfile::query()->where('agent_node_id', $rootId)->update([
|
||||
'total_share_rate' => 100,
|
||||
'default_player_rebate' => 0.005,
|
||||
'rebate_limit' => 0.01,
|
||||
]);
|
||||
|
||||
$leaf = app(AgentNodeService::class)->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'PIPE-X',
|
||||
@@ -192,15 +200,11 @@ test('credit settlement records base rebate plus add-on rebate and keeps extra r
|
||||
'username' => 'pipe_x',
|
||||
'total_share_rate' => 25,
|
||||
'credit_limit' => 100_000,
|
||||
'rebate_limit' => 0.01,
|
||||
'default_player_rebate' => 0.005,
|
||||
'can_grant_extra_rebate' => true,
|
||||
]));
|
||||
|
||||
AgentProfile::query()->where('agent_node_id', $rootId)->update([
|
||||
'total_share_rate' => 100,
|
||||
'default_player_rebate' => 0.005,
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $leaf->id,
|
||||
|
||||
@@ -134,3 +134,56 @@ test('credit player wallet logs distinguish win credit from bill settlement', fu
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.type', 'win_credit');
|
||||
});
|
||||
|
||||
test('credit wallet logs page 2 balance_after continues from page 1 baseline', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:logs-page2',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_logs_page2',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 12,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -1000,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => $i,
|
||||
'created_at' => now()->subMinutes(12 - $i),
|
||||
'updated_at' => now()->subMinutes(12 - $i),
|
||||
]);
|
||||
}
|
||||
|
||||
$page1 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
$page2 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=2&size=10')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
expect($page1['total'])->toBe(12);
|
||||
expect($page2['items'])->toHaveCount(2);
|
||||
|
||||
$page1LastBalanceAfter = $page1['items'][9]['balance_after'];
|
||||
$page2FirstBalanceAfter = $page2['items'][0]['balance_after'];
|
||||
|
||||
// Page 2 must continue the running balance from page 1, not restart at current available.
|
||||
expect($page2FirstBalanceAfter)->toBe($page1LastBalanceAfter + 1000);
|
||||
});
|
||||
|
||||
@@ -4,107 +4,9 @@ use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\WalletTxn;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::create('players', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('site_code');
|
||||
$table->string('site_player_id');
|
||||
$table->string('username')->nullable();
|
||||
$table->string('nickname')->nullable();
|
||||
$table->string('default_currency')->default('NPR');
|
||||
$table->string('funding_mode')->default('wallet');
|
||||
$table->smallInteger('status')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('player_wallets', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('player_id');
|
||||
$table->string('wallet_type');
|
||||
$table->string('currency_code');
|
||||
$table->bigInteger('balance')->default(0);
|
||||
$table->bigInteger('frozen_balance')->default(0);
|
||||
$table->smallInteger('status')->default(0);
|
||||
$table->integer('version')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('wallet_txns', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('txn_no');
|
||||
$table->foreignId('player_id');
|
||||
$table->foreignId('wallet_id');
|
||||
$table->string('biz_type');
|
||||
$table->string('biz_no')->nullable();
|
||||
$table->smallInteger('direction');
|
||||
$table->bigInteger('amount');
|
||||
$table->bigInteger('balance_before');
|
||||
$table->bigInteger('balance_after');
|
||||
$table->string('status');
|
||||
$table->string('external_ref_no')->nullable();
|
||||
$table->string('idempotent_key')->nullable();
|
||||
$table->string('remark')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('transfer_orders', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('transfer_no');
|
||||
$table->foreignId('player_id');
|
||||
$table->string('direction');
|
||||
$table->string('currency_code');
|
||||
$table->bigInteger('amount');
|
||||
$table->string('idempotent_key');
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('player_credit_accounts', function (Blueprint $table): void {
|
||||
$table->foreignId('player_id')->primary();
|
||||
$table->bigInteger('credit_limit')->default(0);
|
||||
$table->bigInteger('used_credit')->default(0);
|
||||
$table->bigInteger('frozen_credit')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('credit_ledger', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('owner_type');
|
||||
$table->unsignedBigInteger('owner_id');
|
||||
$table->bigInteger('amount');
|
||||
$table->string('reason');
|
||||
$table->string('ref_type')->nullable();
|
||||
$table->unsignedBigInteger('ref_id')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('ticket_items', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
});
|
||||
|
||||
Schema::create('settlement_bills', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('bill_type')->default('player');
|
||||
$table->bigInteger('net_amount')->default(0);
|
||||
$table->bigInteger('paid_amount')->default(0);
|
||||
$table->bigInteger('unpaid_amount')->default(0);
|
||||
$table->json('meta_json')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('payment_records', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('settlement_bill_id');
|
||||
$table->bigInteger('amount');
|
||||
$table->string('status');
|
||||
$table->timestamp('confirmed_at')->nullable();
|
||||
});
|
||||
});
|
||||
|
||||
test('financial chain audit passes for consistent wallet ledger', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
|
||||
@@ -124,3 +124,107 @@ test('reversal zeroes share ledger net and marks rebates reversed', function ():
|
||||
expect((string) DB::table('rebate_records')->where('id', $rebateId)->value('status'))->toBe('reversed');
|
||||
expect(DB::table('share_ledger')->where('reversal_of_id', $ledgerId)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('reversal restores credit used for settled win on credit player', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
|
||||
'site_player_id' => 'rev-credit-p1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'revcredit1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 10000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$drawId = (int) \App\Models\Draw::query()->create([
|
||||
'draw_no' => 'REV-CREDIT-DRAW',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => \App\Lottery\DrawStatus::Open->value,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
])->id;
|
||||
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORD-REV-CREDIT-1',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'placed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$itemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'T-REV-CREDIT-1',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'direct',
|
||||
'dimension' => '4d',
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => 'single',
|
||||
'unit_bet_amount' => 100,
|
||||
'total_bet_amount' => 100,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 100,
|
||||
'odds_snapshot_json' => '{}',
|
||||
'rule_snapshot_json' => '{}',
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'win_amount' => 1000,
|
||||
'jackpot_win_amount' => 0,
|
||||
'agent_node_id' => $player->agent_node_id,
|
||||
'share_snapshot' => '{}',
|
||||
'agent_settled_at' => now(),
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $itemId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $player->agent_node_id,
|
||||
'agent_path' => '[]',
|
||||
'share_snapshot' => '{}',
|
||||
'game_win_loss' => -1000,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => -1000,
|
||||
'allocations_json' => '[]',
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Simulate post-win used_credit (win decreased used by 10 major for 1000 minor).
|
||||
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 0]);
|
||||
|
||||
$item = TicketItem::query()->findOrFail($itemId);
|
||||
app(GameSettlementReversalService::class)->reverseTicketItem($item);
|
||||
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10);
|
||||
expect(DB::table('credit_ledger')->where('reason', 'game_settlement_reversal')->where('owner_id', $player->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -6,8 +6,10 @@ use App\Support\PlayerFundingMode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -21,6 +23,24 @@ beforeEach(function (): void {
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{captcha_key: string, captcha_code: string}
|
||||
*/
|
||||
function playerLoginCaptcha(string $code = 'xwz2'): array
|
||||
{
|
||||
$key = (string) Str::uuid();
|
||||
Cache::put(
|
||||
'player_captcha:'.$key,
|
||||
hash_hmac('sha256', strtolower($code), (string) config('app.key')),
|
||||
now()->addSeconds(120),
|
||||
);
|
||||
|
||||
return [
|
||||
'captcha_key' => $key,
|
||||
'captcha_code' => $code,
|
||||
];
|
||||
}
|
||||
|
||||
test('native player can login without site code using default site', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
@@ -38,10 +58,10 @@ test('native player can login without site code using default site', function ()
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$login = $this->postJson('/api/v1/player/auth/login', [
|
||||
$login = $this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'agentplayer0',
|
||||
'password' => 'secret-pass',
|
||||
]);
|
||||
], playerLoginCaptcha()));
|
||||
|
||||
$login->assertOk()
|
||||
->assertJsonPath('data.player.id', $player->id);
|
||||
@@ -81,10 +101,10 @@ test('native player can login without site code on non-default site when usernam
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/player/auth/login', [
|
||||
$this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'play1',
|
||||
'password' => 'secret-pass',
|
||||
])
|
||||
], playerLoginCaptcha()))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.player.id', $player->id)
|
||||
->assertJsonPath('data.player.site_code', 'kk88');
|
||||
@@ -132,10 +152,47 @@ test('native player login without site code rejects ambiguous username across si
|
||||
]);
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/player/auth/login', [
|
||||
$this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'dup_user',
|
||||
'password' => 'secret-pass',
|
||||
])->assertJsonPath('code', 8006);
|
||||
], playerLoginCaptcha()))->assertJsonPath('code', 8006);
|
||||
});
|
||||
|
||||
test('player auth captcha exposes key and image base64', function (): void {
|
||||
$resp = $this->getJson('/api/v1/player/auth/captcha');
|
||||
|
||||
$resp->assertOk();
|
||||
$data = $resp->json('data');
|
||||
expect($data)->toHaveKeys(['captcha_key', 'image_base64'])
|
||||
->and(Str::isUuid((string) $data['captcha_key']))->toBeTrue()
|
||||
->and(base64_decode((string) $data['image_base64'], true))->not->toBeFalse();
|
||||
});
|
||||
|
||||
test('native player login rejects invalid captcha', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:captcha-fail',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'captchafail',
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$captcha = playerLoginCaptcha('xwz2');
|
||||
|
||||
$this->postJson('/api/v1/player/auth/login', [
|
||||
'username' => 'captchafail',
|
||||
'password' => 'secret-pass',
|
||||
'captcha_key' => $captcha['captcha_key'],
|
||||
'captcha_code' => 'aaaa',
|
||||
])->assertStatus(422)->assertJsonPath('code', 8009);
|
||||
});
|
||||
|
||||
test('native player can login and access me', function (): void {
|
||||
@@ -164,11 +221,11 @@ test('native player can login and access me', function (): void {
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$login = $this->postJson('/api/v1/player/auth/login', [
|
||||
$login = $this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'site_code' => $site->code,
|
||||
'username' => 'agentplayer1',
|
||||
'password' => 'secret-pass',
|
||||
]);
|
||||
], playerLoginCaptcha()));
|
||||
|
||||
$login->assertOk();
|
||||
$token = (string) $login->json('data.access_token');
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\Ticket\RiskPoolService;
|
||||
use App\Services\Wallet\LotteryTransferService;
|
||||
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
@@ -20,7 +21,9 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config(['broadcasting.default' => 'reverb']);
|
||||
config(['lottery.main_site.wallet_api_url' => null]);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('wallet balance notifier dispatches balance update broadcast', function (): void {
|
||||
|
||||
@@ -15,4 +15,5 @@ test('major and minor convert for two decimal currency', function (): void {
|
||||
expect(CreditAmountScale::minorToMajor(20000, 'NPR'))->toBe(200);
|
||||
expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3);
|
||||
expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2);
|
||||
expect(CreditAmountScale::minorToMajor(201, 'NPR'))->toBe(3);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user