fix(core): harden settlement and wallet integration
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Services\Integration\PartnerSiteConfig;
|
||||
use App\Services\Integration\PartnerSiteConfigResolver;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use App\Services\Integration\PartnerSiteConfig;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Integration\PartnerSiteConfigResolver;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
fakeWalletApiDns();
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
@@ -221,7 +223,81 @@ test('connectivity test probes partner balance api', function (): void {
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.probe.success', true)
|
||||
->assertJsonPath('data.probe.main_balance_minor', 12345);
|
||||
->assertJsonPath('data.probe.main_balance_minor', 12345)
|
||||
->assertJsonMissingPath('data.probe.response_preview');
|
||||
});
|
||||
|
||||
test('connectivity test rejects hostname resolving to a private address before sending', function (): void {
|
||||
fakeWalletApiDns([
|
||||
'wallet.private.test' => ['93.184.216.34', '10.0.0.8'],
|
||||
], []);
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$token = integrationAdminToken();
|
||||
$create = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'private-probe-site',
|
||||
'name' => 'Private Probe',
|
||||
'wallet_api_url' => 'https://wallet.private.test',
|
||||
'admin_account' => [
|
||||
'username' => 'private_probe_admin',
|
||||
'nickname' => 'Private Probe Admin',
|
||||
'password' => 'secret-strong',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [
|
||||
'site_player_id' => '10001',
|
||||
'currency_code' => 'NPR',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.probe.success', false)
|
||||
->assertJsonPath('data.probe.http_status', null)
|
||||
->assertJsonPath('data.probe.message', 'wallet_api_url 无效(拒绝以防 SSRF)')
|
||||
->assertJsonMissingPath('data.probe.response_preview');
|
||||
|
||||
Http::assertSentCount(0);
|
||||
});
|
||||
|
||||
test('connectivity test does not follow redirects or expose response preview', function (): void {
|
||||
fakeWalletApiDns([
|
||||
'wallet.redirect.test' => ['93.184.216.34'],
|
||||
], []);
|
||||
Http::fake([
|
||||
'https://wallet.redirect.test/*' => Http::response(
|
||||
['secret' => 'must-not-be-returned'],
|
||||
302,
|
||||
['Location' => 'https://169.254.169.254/latest/meta-data'],
|
||||
),
|
||||
'https://169.254.169.254/*' => Http::response(['role' => 'internal'], 200),
|
||||
]);
|
||||
|
||||
$token = integrationAdminToken();
|
||||
$create = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'redirect-probe-site',
|
||||
'name' => 'Redirect Probe',
|
||||
'wallet_api_url' => 'https://wallet.redirect.test',
|
||||
'admin_account' => [
|
||||
'username' => 'redirect_probe_admin',
|
||||
'nickname' => 'Redirect Probe Admin',
|
||||
'password' => 'secret-strong',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [
|
||||
'site_player_id' => '10001',
|
||||
'currency_code' => 'NPR',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.probe.success', false)
|
||||
->assertJsonPath('data.probe.http_status', 302)
|
||||
->assertJsonMissingPath('data.probe.response_preview')
|
||||
->assertJsonMissing(['must-not-be-returned']);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('export parameter sheet excludes plaintext secrets', function (): void {
|
||||
@@ -329,7 +405,7 @@ test('site scoped admin only sees bound integration sites', function (): void {
|
||||
});
|
||||
|
||||
test('player list is filtered by admin site binding', function (): void {
|
||||
$this->seed(\Database\Seeders\CurrencySeeder::class);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
|
||||
Player::query()->create([
|
||||
'site_code' => 'site-a',
|
||||
|
||||
234
tests/Feature/AdminReportJobScopeTest.php
Normal file
234
tests/Feature/AdminReportJobScopeTest.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\ReportJob;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use App\Services\AuditLogger;
|
||||
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);
|
||||
});
|
||||
|
||||
/**
|
||||
* @param list<string> $permissionCodes
|
||||
*/
|
||||
function makeReportScopeAdmin(string $username, int $siteId, array $permissionCodes): AdminUser
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => $username,
|
||||
'name' => $username,
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'report_scope_'.$username,
|
||||
'code' => 'report_scope_'.$username,
|
||||
'name' => 'Report Scope '.$username,
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$actionIds = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', $permissionCodes)
|
||||
->pluck('id');
|
||||
|
||||
foreach ($actionIds as $actionId) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => (int) $role->id,
|
||||
'menu_action_id' => (int) $actionId,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => (int) $admin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => (int) $role->id,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
function makeReportScopeJob(AdminUser $owner, string $jobNo, string $reportType = 'daily_profit_summary'): ReportJob
|
||||
{
|
||||
return ReportJob::query()->create([
|
||||
'job_no' => $jobNo,
|
||||
'admin_user_id' => (int) $owner->id,
|
||||
'report_type' => $reportType,
|
||||
'export_format' => 'csv',
|
||||
'filter_json' => [
|
||||
'date_from' => now()->toDateString(),
|
||||
'date_to' => now()->toDateString(),
|
||||
],
|
||||
'status' => 'completed',
|
||||
'output_path' => 'reports/'.$jobNo.'.csv',
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function makeReportScopeSuperAdmin(): AdminUser
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'report_scope_super',
|
||||
'name' => 'Report Scope Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('report job list show and download are owner only with an explicit super admin exception', function (): void {
|
||||
$siteAId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteB = AdminSite::query()->create([
|
||||
'code' => 'report-scope-b',
|
||||
'name' => 'Report Scope B',
|
||||
'currency_code' => 'NPR',
|
||||
'status' => 1,
|
||||
'is_default' => false,
|
||||
]);
|
||||
$permissions = ['service.report.view', 'service.report.export'];
|
||||
$owner = makeReportScopeAdmin('report_owner', $siteAId, $permissions);
|
||||
$sameSitePeer = makeReportScopeAdmin('report_same_site_peer', $siteAId, $permissions);
|
||||
$crossSitePeer = makeReportScopeAdmin('report_cross_site_peer', (int) $siteB->id, $permissions);
|
||||
|
||||
$ownedJob = makeReportScopeJob($owner, 'RPT-SCOPE-OWN');
|
||||
$sameSiteJob = makeReportScopeJob($sameSitePeer, 'RPT-SCOPE-SAME');
|
||||
$crossSiteJob = makeReportScopeJob($crossSitePeer, 'RPT-SCOPE-CROSS');
|
||||
|
||||
$deletedCreator = makeReportScopeAdmin('report_deleted_creator', $siteAId, $permissions);
|
||||
$orphanedJob = makeReportScopeJob($deletedCreator, 'RPT-SCOPE-ORPHAN');
|
||||
$deletedCreator->delete();
|
||||
$orphanedJob->refresh();
|
||||
expect($orphanedJob->admin_user_id)->toBeNull();
|
||||
|
||||
Sanctum::actingAs($owner, ['*']);
|
||||
$visibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
|
||||
->assertOk()
|
||||
->json('data.items'))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
expect($visibleIds)->toBe([(int) $ownedJob->id]);
|
||||
|
||||
$this->getJson('/api/v1/admin/report-jobs/'.$ownedJob->id)->assertOk();
|
||||
$this->get('/api/v1/admin/report-jobs/'.$ownedJob->id.'/download')->assertOk();
|
||||
|
||||
foreach ([$sameSiteJob, $crossSiteJob, $orphanedJob] as $deniedJob) {
|
||||
$this->getJson('/api/v1/admin/report-jobs/'.$deniedJob->id)->assertForbidden();
|
||||
$this->get('/api/v1/admin/report-jobs/'.$deniedJob->id.'/download')->assertForbidden();
|
||||
}
|
||||
|
||||
$super = makeReportScopeSuperAdmin();
|
||||
Sanctum::actingAs($super, ['*']);
|
||||
$superVisibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
|
||||
->assertOk()
|
||||
->json('data.items'))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
expect($superVisibleIds)->toContain(
|
||||
(int) $ownedJob->id,
|
||||
(int) $sameSiteJob->id,
|
||||
(int) $crossSiteJob->id,
|
||||
(int) $orphanedJob->id,
|
||||
);
|
||||
|
||||
$this->getJson('/api/v1/admin/report-jobs/'.$orphanedJob->id)->assertOk();
|
||||
$this->get('/api/v1/admin/report-jobs/'.$orphanedJob->id.'/download')->assertOk();
|
||||
});
|
||||
|
||||
test('sensitive report types require their own capabilities and risk export is super admin only', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$base = makeReportScopeAdmin('report_base_exporter', $siteId, [
|
||||
'service.report.view',
|
||||
'service.report.export',
|
||||
]);
|
||||
$auditor = makeReportScopeAdmin('report_auditor', $siteId, [
|
||||
'service.report.view',
|
||||
'service.report.export',
|
||||
'service.audit.view',
|
||||
]);
|
||||
$riskViewer = makeReportScopeAdmin('report_risk_viewer', $siteId, [
|
||||
'service.report.view',
|
||||
'service.report.export',
|
||||
'risk.monitor.view',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($base, ['*']);
|
||||
$this->postJson('/api/v1/admin/report-jobs', [
|
||||
'report_type' => 'audit_operation_report',
|
||||
])->assertForbidden();
|
||||
$legacyAuditJob = makeReportScopeJob($base, 'RPT-SENSITIVE-AUDIT', 'audit_operation_report');
|
||||
$this->get('/api/v1/admin/report-jobs/'.$legacyAuditJob->id.'/download')->assertForbidden();
|
||||
|
||||
Sanctum::actingAs($auditor, ['*']);
|
||||
$this->postJson('/api/v1/admin/report-jobs', [
|
||||
'report_type' => 'audit_operation_report',
|
||||
])->assertOk();
|
||||
|
||||
Sanctum::actingAs($riskViewer, ['*']);
|
||||
$legacyRiskJob = makeReportScopeJob($riskViewer, 'RPT-SENSITIVE-RISK', 'hot_number_risk_report');
|
||||
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
|
||||
$this->postJson('/api/v1/admin/report-jobs', [
|
||||
'report_type' => $reportType,
|
||||
])->assertForbidden();
|
||||
}
|
||||
$this->get('/api/v1/admin/report-jobs/'.$legacyRiskJob->id.'/download')->assertForbidden();
|
||||
|
||||
$super = makeReportScopeSuperAdmin();
|
||||
Sanctum::actingAs($super, ['*']);
|
||||
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
|
||||
$created = $this->postJson('/api/v1/admin/report-jobs', [
|
||||
'report_type' => $reportType,
|
||||
])->assertOk();
|
||||
$this->get('/api/v1/admin/report-jobs/'.(int) $created->json('data.id').'/download')->assertOk();
|
||||
}
|
||||
});
|
||||
|
||||
test('non super audit export is limited to the current actor', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$auditor = makeReportScopeAdmin('report_audit_owner', $siteId, [
|
||||
'service.report.view',
|
||||
'service.report.export',
|
||||
'service.audit.view',
|
||||
]);
|
||||
$other = makeReportScopeAdmin('report_audit_other', $siteId, [
|
||||
'service.report.view',
|
||||
'service.report.export',
|
||||
'service.audit.view',
|
||||
]);
|
||||
|
||||
AuditLogger::recordForAdmin($auditor, null, 'audit_scope_own', 'own_action', null, null, null, null);
|
||||
AuditLogger::recordForAdmin($other, null, 'audit_scope_other', 'other_action', null, null, null, null);
|
||||
|
||||
Sanctum::actingAs($auditor, ['*']);
|
||||
$create = $this->postJson('/api/v1/admin/report-jobs', [
|
||||
'report_type' => 'audit_operation_report',
|
||||
'export_format' => 'csv',
|
||||
'parameters' => [
|
||||
'date_from' => now()->toDateString(),
|
||||
'date_to' => now()->toDateString(),
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
$content = $this->get('/api/v1/admin/report-jobs/'.(int) $create->json('data.id').'/download')
|
||||
->assertOk()
|
||||
->streamedContent();
|
||||
|
||||
expect($content)->toContain('audit_scope_own')
|
||||
->not->toContain('audit_scope_other');
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Services\AgentSettlement\GameSettlementReversalService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\AgentSettlement\GameSettlementReversalService;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -31,11 +34,11 @@ test('reversal zeroes share ledger net and marks rebates reversed', function ():
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$drawId = (int) \App\Models\Draw::query()->create([
|
||||
$drawId = (int) Draw::query()->create([
|
||||
'draw_no' => 'REV-DRAW-1',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => \App\Lottery\DrawStatus::Open->value,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
@@ -148,11 +151,11 @@ test('reversal restores credit used for settled win on credit player', function
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$drawId = (int) \App\Models\Draw::query()->create([
|
||||
$drawId = (int) Draw::query()->create([
|
||||
'draw_no' => 'REV-CREDIT-DRAW',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => \App\Lottery\DrawStatus::Open->value,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
@@ -219,12 +222,52 @@ test('reversal restores credit used for settled win on credit player', function
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Simulate post-win used_credit (win decreased used by 10 major for 1000 minor).
|
||||
// 模拟结算前已有 11 主单位占额:释放本注 1,再以中奖额释放 10,结算后为 0。
|
||||
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((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(11);
|
||||
expect(DB::table('credit_ledger')->where('reason', 'game_settlement_reversal')->where('owner_id', $player->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('reversal restores only the credit actually released by an oversized win', 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-clipped',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'revcreditclipped',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 10000,
|
||||
'used_credit' => 1,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$credit = app(PlayerCreditService::class);
|
||||
$credit->releaseBetHold($player, 100, 99881, 1);
|
||||
$credit->applySettledWin($player, 1000, 99881, 1);
|
||||
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0)
|
||||
->and((int) DB::table('credit_ledger')
|
||||
->where('ref_id', 99881)
|
||||
->where('reason', 'game_settlement_win')
|
||||
->where('settlement_version', 1)
|
||||
->value('amount'))->toBe(0);
|
||||
|
||||
$credit->reverseGameSettlement($player, -1000, 99881, 1);
|
||||
$credit->restoreBetHoldAfterSettlementReversal($player, 100, 99881, 1);
|
||||
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -116,6 +116,37 @@ test('jwt first successful login auto-registers player mapping', function () {
|
||||
->and($player->nickname)->toBe($username);
|
||||
});
|
||||
|
||||
test('main site sso jwt cannot take over an existing native credit player mapping', function (): void {
|
||||
config(['lottery.player_auth.dev_bypass' => false]);
|
||||
config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret-at-least-32-bytes-long']);
|
||||
|
||||
$native = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'native-sso-collision',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'native_collision',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$now = time();
|
||||
$jwt = JWT::encode([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => $native->site_player_id,
|
||||
'iat' => $now,
|
||||
'exp' => $now + 300,
|
||||
], 'jwt-test-secret-at-least-32-bytes-long', 'HS256');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$jwt)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
|
||||
|
||||
expect($native->fresh()->last_login_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('player me rejects non-active status with 8005', function () {
|
||||
$code = ErrorCode::PlayerAccountSuspended->value;
|
||||
$player = Player::query()->create([
|
||||
|
||||
174
tests/Feature/PlayerNativeJwtSecretGuardTest.php
Normal file
174
tests/Feature/PlayerNativeJwtSecretGuardTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use App\Services\Player\PlayerNativeAuthService;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config([
|
||||
'lottery.player_auth.native.secret' => 'independent-native-secret-32bytes!!',
|
||||
'lottery.player_auth.native.ttl_seconds' => 3600,
|
||||
'lottery.main_site.sso_jwt_secret' => null,
|
||||
'lottery.main_site.wallet_api_url' => null,
|
||||
]);
|
||||
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
function nativeSecretGuardPlayer(string $suffix): Player
|
||||
{
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
return Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:secret-guard-'.$suffix,
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'secret_guard_'.$suffix,
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function nativeSecretGuardToken(Player $player, string $secret): string
|
||||
{
|
||||
$now = time();
|
||||
|
||||
return JWT::encode([
|
||||
'player_id' => (int) $player->id,
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'token_version' => (int) ($player->native_token_version ?? 0),
|
||||
'site_code' => (string) $player->site_code,
|
||||
'iat' => $now,
|
||||
'exp' => $now + 3600,
|
||||
], $secret, 'HS256');
|
||||
}
|
||||
|
||||
function expectNativeSecretConfigurationRejected(Closure $callback): void
|
||||
{
|
||||
try {
|
||||
$callback();
|
||||
test()->fail('Expected native JWT secret configuration to be rejected.');
|
||||
} catch (PlayerAuthenticationException $exception) {
|
||||
expect($exception->lotteryCode)->toBe(ErrorCode::PlayerSsoSecretNotConfigured->value)
|
||||
->and($exception->httpStatus)->toBe(503);
|
||||
}
|
||||
}
|
||||
|
||||
test('missing native secret rejects token issuance and verification with 503', function (): void {
|
||||
$player = nativeSecretGuardPlayer('missing');
|
||||
config(['lottery.player_auth.native.secret' => null]);
|
||||
|
||||
expectNativeSecretConfigurationRejected(
|
||||
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
|
||||
);
|
||||
|
||||
$token = nativeSecretGuardToken($player, 'previous-native-secret-32bytes!!');
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
|
||||
});
|
||||
|
||||
test('native secret matching legacy sso secret rejects token issuance and verification', function (): void {
|
||||
$player = nativeSecretGuardPlayer('legacy-match');
|
||||
$sharedSecret = 'shared-legacy-native-secret-32bytes!!';
|
||||
config([
|
||||
'lottery.player_auth.native.secret' => $sharedSecret,
|
||||
'lottery.main_site.sso_jwt_secret' => $sharedSecret,
|
||||
]);
|
||||
|
||||
expectNativeSecretConfigurationRejected(
|
||||
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
|
||||
);
|
||||
|
||||
$token = nativeSecretGuardToken($player, $sharedSecret);
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
|
||||
});
|
||||
|
||||
test('native secret matching enabled database site sso secret rejects issuance and verification', function (): void {
|
||||
$player = nativeSecretGuardPlayer('database-match');
|
||||
$sharedSecret = 'shared-database-native-secret-32bytes!!';
|
||||
config([
|
||||
'lottery.player_auth.native.secret' => $sharedSecret,
|
||||
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
|
||||
]);
|
||||
DB::table('admin_sites')->where('is_default', true)->update([
|
||||
'status' => 1,
|
||||
'sso_jwt_secret_encrypted' => encrypt($sharedSecret),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
expectNativeSecretConfigurationRejected(
|
||||
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
|
||||
);
|
||||
|
||||
$token = nativeSecretGuardToken($player, $sharedSecret);
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
|
||||
});
|
||||
|
||||
test('native secret matching disabled database site sso secret rejects issuance and verification', function (): void {
|
||||
$player = nativeSecretGuardPlayer('disabled-database-match');
|
||||
$sharedSecret = 'shared-disabled-site-secret-32bytes!!';
|
||||
config([
|
||||
'lottery.player_auth.native.secret' => $sharedSecret,
|
||||
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
|
||||
]);
|
||||
DB::table('admin_sites')->where('is_default', true)->update([
|
||||
'status' => 0,
|
||||
'sso_jwt_secret_encrypted' => encrypt($sharedSecret),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
expectNativeSecretConfigurationRejected(
|
||||
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
|
||||
);
|
||||
|
||||
$token = nativeSecretGuardToken($player, $sharedSecret);
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
|
||||
});
|
||||
|
||||
test('independent native secret still issues and verifies tokens', function (): void {
|
||||
$player = nativeSecretGuardPlayer('independent');
|
||||
config([
|
||||
'lottery.player_auth.native.secret' => 'independent-native-secret-32bytes!!',
|
||||
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
|
||||
]);
|
||||
DB::table('admin_sites')->where('is_default', true)->update([
|
||||
'status' => 1,
|
||||
'sso_jwt_secret_encrypted' => encrypt('different-database-sso-secret-32bytes!!'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$token = app(PlayerNativeAuthService::class)->issueToken($player);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $player->id)
|
||||
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
|
||||
});
|
||||
@@ -1,21 +1,25 @@
|
||||
<?php
|
||||
|
||||
use App\Events\BalanceUpdateBroadcast;
|
||||
use App\Events\PlayCatalogUpdatedBroadcast;
|
||||
use App\Events\RiskSoldOutBroadcast;
|
||||
use App\Events\RiskWarningBroadcast;
|
||||
use App\Services\Config\RiskCapStreamService;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\RiskPool;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
use App\Services\Wallet\LotteryTransferService;
|
||||
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Events\RiskSoldOutBroadcast;
|
||||
use App\Events\RiskWarningBroadcast;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use App\Events\BalanceUpdateBroadcast;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
use App\Events\PlayCatalogUpdatedBroadcast;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use App\Services\Config\RiskCapStreamService;
|
||||
use App\Services\Wallet\LotteryTransferService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -26,6 +30,55 @@ beforeEach(function (): void {
|
||||
$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('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]);
|
||||
|
||||
@@ -109,11 +162,11 @@ test('risk pool acquire dispatches warning and sold out broadcasts', function ()
|
||||
test('risk cap publish dispatches play catalog updated broadcast', function (): void {
|
||||
Event::fake([PlayCatalogUpdatedBroadcast::class]);
|
||||
|
||||
$admin = \App\Models\AdminUser::query()->create([
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'risk_cap_admin',
|
||||
'name' => 'Risk Cap QA',
|
||||
'email' => null,
|
||||
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'),
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
401
tests/Feature/SettlementChunkAndProviderPayoutTest.php
Normal file
401
tests/Feature/SettlementChunkAndProviderPayoutTest.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Models\TicketCombination;
|
||||
use App\Services\LotterySettings;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\SettlementTickFinalizer;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createChunkTestDraw(string $suffix): Draw
|
||||
{
|
||||
return Draw::query()->create([
|
||||
'draw_no' => 'CHUNK-'.$suffix,
|
||||
'business_date' => '2026-07-22',
|
||||
'sequence_no' => random_int(1000, 9999),
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'start_time' => now()->subMinutes(10),
|
||||
'close_time' => now()->subMinutes(2),
|
||||
'draw_time' => now()->subMinute(),
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
function createChunkTestPlayer(string $suffix): Player
|
||||
{
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'chunk-'.$suffix,
|
||||
'username' => 'chunk_'.$suffix,
|
||||
'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,
|
||||
]);
|
||||
|
||||
return $player;
|
||||
}
|
||||
|
||||
function createChunkTestResult(Draw $draw, string $providerCode): DrawResultBatch
|
||||
{
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => $providerCode,
|
||||
'provider_name' => $providerCode,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'manual',
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$number = $slot['prize_type'] === 'first' ? '1234' : '5678';
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $number,
|
||||
'suffix_3d' => substr($number, -3),
|
||||
'suffix_2d' => substr($number, -2),
|
||||
'head_digit' => (int) $number[0],
|
||||
'tail_digit' => (int) $number[3],
|
||||
]);
|
||||
}
|
||||
|
||||
return $batch;
|
||||
}
|
||||
|
||||
function createChunkTestTicket(
|
||||
Draw $draw,
|
||||
Player $player,
|
||||
TicketOrder $order,
|
||||
string $providerCode,
|
||||
string $suffix,
|
||||
): TicketItem {
|
||||
$item = TicketItem::query()->create([
|
||||
'ticket_no' => 'CHUNK-TICKET-'.$suffix,
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => $providerCode,
|
||||
'provider_name' => $providerCode,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'pos_4a',
|
||||
'bet_mode' => 'unit',
|
||||
'unit_bet_amount' => 10_000,
|
||||
'total_bet_amount' => 10_000,
|
||||
'actual_deduct_amount' => 10_000,
|
||||
'odds_snapshot_json' => [['prize_scope' => 'first', 'odds_value' => 100_000]],
|
||||
'rule_snapshot_json' => [],
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 100_000,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'pending_draw',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 1,
|
||||
'number_4d' => '1234',
|
||||
'bet_amount' => 10_000,
|
||||
'estimated_payout' => 100_000,
|
||||
]);
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
function createChunkTestOrder(Draw $draw, Player $player, string $suffix, int $ticketCount): TicketOrder
|
||||
{
|
||||
return TicketOrder::query()->create([
|
||||
'order_no' => 'CHUNK-ORDER-'.$suffix,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 10_000 * $ticketCount,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 10_000 * $ticketCount,
|
||||
'total_estimated_payout' => 100_000 * $ticketCount,
|
||||
'status' => 'placed',
|
||||
'submit_source' => 'test',
|
||||
'client_trace_id' => 'chunk-trace-'.$suffix,
|
||||
]);
|
||||
}
|
||||
|
||||
test('settlement processes every ticket chunk into one provider batch', function (): void {
|
||||
config()->set('lottery.settlement.ticket_chunk_size', 2);
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$draw = createChunkTestDraw($suffix);
|
||||
$player = createChunkTestPlayer($suffix);
|
||||
$order = createChunkTestOrder($draw, $player, $suffix, 3);
|
||||
createChunkTestResult($draw, 'SG');
|
||||
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-'.$i);
|
||||
}
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue();
|
||||
|
||||
$batch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1)
|
||||
->and($batch->status)->toBe(SettlementBatchStatus::PendingReview->value)
|
||||
->and((int) $batch->total_ticket_count)->toBe(3)
|
||||
->and((int) $batch->total_win_count)->toBe(3)
|
||||
->and((int) $batch->total_payout_amount)->toBe(300_000)
|
||||
->and($batch->details()->count())->toBe(3)
|
||||
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('settlement resumes a legacy partial provider batch instead of skipping remaining tickets', function (): void {
|
||||
config()->set('lottery.settlement.ticket_chunk_size', 2);
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$draw = createChunkTestDraw($suffix);
|
||||
$player = createChunkTestPlayer($suffix);
|
||||
$order = createChunkTestOrder($draw, $player, $suffix, 3);
|
||||
createChunkTestResult($draw, 'SG');
|
||||
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-1');
|
||||
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-2');
|
||||
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($draw);
|
||||
$existingBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
|
||||
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-3');
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
$resumedBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect((int) $resumedBatch->id)->toBe((int) $existingBatch->id)
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1)
|
||||
->and($resumedBatch->status)->toBe(SettlementBatchStatus::PendingReview->value)
|
||||
->and((int) $resumedBatch->total_ticket_count)->toBe(3)
|
||||
->and($resumedBatch->details()->count())->toBe(3)
|
||||
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('approved provider batches payout independently and settle draw after the last provider', function (): void {
|
||||
config()->set('lottery.settlement.ticket_chunk_size', 1);
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$draw = createChunkTestDraw($suffix);
|
||||
$player = createChunkTestPlayer($suffix);
|
||||
$order = createChunkTestOrder($draw, $player, $suffix, 2);
|
||||
|
||||
foreach (['SG', 'MY'] as $providerCode) {
|
||||
createChunkTestResult($draw, $providerCode);
|
||||
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
|
||||
}
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue();
|
||||
|
||||
$batches = SettlementBatch::query()->where('draw_id', $draw->id)->orderBy('id')->get();
|
||||
expect($batches)->toHaveCount(2)
|
||||
->and($batches->every(fn (SettlementBatch $batch): bool => $batch->status === SettlementBatchStatus::PendingReview->value))->toBeTrue();
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'chunk_reviewer_'.$suffix,
|
||||
'name' => 'Chunk Reviewer',
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
$workflow = app(SettlementBatchWorkflowService::class);
|
||||
foreach ($batches as $batch) {
|
||||
$workflow->approve($batch, $admin);
|
||||
}
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue()
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2);
|
||||
|
||||
$workflow->payout($batches[0]->fresh());
|
||||
expect($draw->fresh()->status)->toBe(DrawStatus::Settling->value)
|
||||
->and(SettlementBatch::query()->whereKey($batches[0]->id)->value('status'))->toBe(SettlementBatchStatus::Paid->value)
|
||||
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_payout')->count())->toBe(1);
|
||||
|
||||
$workflow->payout($batches[1]->fresh());
|
||||
expect($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
|
||||
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2)
|
||||
->and($order->fresh()->status)->toBe('settled');
|
||||
});
|
||||
|
||||
test('tick finalizer approves all provider batches before paying them', function (): void {
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$draw = createChunkTestDraw($suffix);
|
||||
$player = createChunkTestPlayer($suffix);
|
||||
$order = createChunkTestOrder($draw, $player, $suffix, 2);
|
||||
|
||||
foreach (['SG', 'MY'] as $providerCode) {
|
||||
createChunkTestResult($draw, $providerCode);
|
||||
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
|
||||
}
|
||||
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($draw);
|
||||
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($result)->toMatchArray(['approved' => 2, 'paid' => 2, 'payout_failed' => 0])
|
||||
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
|
||||
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('tick finalizer resumes approved batches left before payout', function (): void {
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$draw = createChunkTestDraw($suffix);
|
||||
$player = createChunkTestPlayer($suffix);
|
||||
$order = createChunkTestOrder($draw, $player, $suffix, 2);
|
||||
|
||||
foreach (['SG', 'MY'] as $providerCode) {
|
||||
createChunkTestResult($draw, $providerCode);
|
||||
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
|
||||
}
|
||||
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($draw);
|
||||
$workflow = app(SettlementBatchWorkflowService::class);
|
||||
foreach (SettlementBatch::query()->where('draw_id', $draw->id)->get() as $batch) {
|
||||
$workflow->approveBySystem($batch, 'simulate restart after approval');
|
||||
}
|
||||
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::PendingReview->value)->exists())->toBeFalse()
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2);
|
||||
|
||||
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($result)->toMatchArray(['approved' => 0, 'paid' => 2, 'payout_failed' => 0])
|
||||
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
|
||||
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('blocked approved draw does not starve a later eligible draw at the scan limit', function (): void {
|
||||
config()->set('lottery.draw_tick_finalize_limit', 1);
|
||||
|
||||
$blockedSuffix = bin2hex(random_bytes(4));
|
||||
$blockedDraw = createChunkTestDraw($blockedSuffix);
|
||||
$blockedResult = createChunkTestResult($blockedDraw, 'BLOCKED');
|
||||
foreach ([SettlementBatchStatus::Approved, SettlementBatchStatus::Running] as $status) {
|
||||
SettlementBatch::query()->create([
|
||||
'draw_id' => $blockedDraw->id,
|
||||
'result_batch_id' => $blockedResult->id,
|
||||
'settle_version' => 1,
|
||||
'status' => $status->value,
|
||||
'total_ticket_count' => 0,
|
||||
'total_win_count' => 0,
|
||||
'total_payout_amount' => 0,
|
||||
'total_jackpot_payout_amount' => 0,
|
||||
'review_status' => $status === SettlementBatchStatus::Approved ? 'approved' : 'pending',
|
||||
'reviewed_at' => $status === SettlementBatchStatus::Approved ? now() : null,
|
||||
'started_at' => now(),
|
||||
'finished_at' => $status === SettlementBatchStatus::Approved ? now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$eligibleSuffix = bin2hex(random_bytes(4));
|
||||
$eligibleDraw = createChunkTestDraw($eligibleSuffix);
|
||||
$player = createChunkTestPlayer($eligibleSuffix);
|
||||
$order = createChunkTestOrder($eligibleDraw, $player, $eligibleSuffix, 1);
|
||||
createChunkTestResult($eligibleDraw, 'SG');
|
||||
createChunkTestTicket($eligibleDraw, $player, $order, 'SG', $eligibleSuffix.'-SG');
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($eligibleDraw);
|
||||
$eligibleBatch = SettlementBatch::query()->where('draw_id', $eligibleDraw->id)->firstOrFail();
|
||||
app(SettlementBatchWorkflowService::class)->approveBySystem($eligibleBatch, 'simulate approved backlog');
|
||||
|
||||
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
|
||||
->and($eligibleBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
|
||||
->and($eligibleDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $blockedDraw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('manual approval still allows automatic payout when auto approval is disabled', function (): void {
|
||||
$approvedSuffix = bin2hex(random_bytes(4));
|
||||
$approvedDraw = createChunkTestDraw($approvedSuffix);
|
||||
$approvedPlayer = createChunkTestPlayer($approvedSuffix);
|
||||
$approvedOrder = createChunkTestOrder($approvedDraw, $approvedPlayer, $approvedSuffix, 1);
|
||||
createChunkTestResult($approvedDraw, 'SG');
|
||||
createChunkTestTicket($approvedDraw, $approvedPlayer, $approvedOrder, 'SG', $approvedSuffix.'-SG');
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($approvedDraw);
|
||||
$approvedBatch = SettlementBatch::query()->where('draw_id', $approvedDraw->id)->firstOrFail();
|
||||
app(SettlementBatchWorkflowService::class)->approveBySystem($approvedBatch, 'manual approval simulation');
|
||||
|
||||
$pendingSuffix = bin2hex(random_bytes(4));
|
||||
$pendingDraw = createChunkTestDraw($pendingSuffix);
|
||||
$pendingPlayer = createChunkTestPlayer($pendingSuffix);
|
||||
$pendingOrder = createChunkTestOrder($pendingDraw, $pendingPlayer, $pendingSuffix, 1);
|
||||
createChunkTestResult($pendingDraw, 'MY');
|
||||
createChunkTestTicket($pendingDraw, $pendingPlayer, $pendingOrder, 'MY', $pendingSuffix.'-MY');
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($pendingDraw);
|
||||
$pendingBatch = SettlementBatch::query()->where('draw_id', $pendingDraw->id)->firstOrFail();
|
||||
|
||||
LotterySettings::put('settlement.auto_approve_on_tick', false, 'settlement', 'test manual approval mode');
|
||||
LotterySettings::put('settlement.auto_payout_on_tick', true, 'settlement', 'test automatic payout mode');
|
||||
|
||||
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
|
||||
->and($approvedBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
|
||||
->and($approvedDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
|
||||
->and($pendingBatch->fresh()->status)->toBe(SettlementBatchStatus::PendingReview->value)
|
||||
->and($pendingDraw->fresh()->status)->toBe(DrawStatus::Settling->value);
|
||||
});
|
||||
|
||||
test('a repeatedly failing approved draw does not starve a later healthy draw', function (): void {
|
||||
config()->set('lottery.draw_tick_finalize_limit', 1);
|
||||
|
||||
$poisonSuffix = bin2hex(random_bytes(4));
|
||||
$poisonDraw = createChunkTestDraw($poisonSuffix);
|
||||
$poisonPlayer = createChunkTestPlayer($poisonSuffix);
|
||||
$poisonOrder = createChunkTestOrder($poisonDraw, $poisonPlayer, $poisonSuffix, 1);
|
||||
createChunkTestResult($poisonDraw, 'SG');
|
||||
createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-SG');
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($poisonDraw);
|
||||
$poisonBatch = SettlementBatch::query()->where('draw_id', $poisonDraw->id)->firstOrFail();
|
||||
app(SettlementBatchWorkflowService::class)->approveBySystem($poisonBatch, 'simulate poison batch');
|
||||
createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-ORPHAN');
|
||||
|
||||
$healthySuffix = bin2hex(random_bytes(4));
|
||||
$healthyDraw = createChunkTestDraw($healthySuffix);
|
||||
$healthyPlayer = createChunkTestPlayer($healthySuffix);
|
||||
$healthyOrder = createChunkTestOrder($healthyDraw, $healthyPlayer, $healthySuffix, 1);
|
||||
createChunkTestResult($healthyDraw, 'MY');
|
||||
createChunkTestTicket($healthyDraw, $healthyPlayer, $healthyOrder, 'MY', $healthySuffix.'-MY');
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($healthyDraw);
|
||||
$healthyBatch = SettlementBatch::query()->where('draw_id', $healthyDraw->id)->firstOrFail();
|
||||
app(SettlementBatchWorkflowService::class)->approveBySystem($healthyBatch, 'simulate healthy backlog');
|
||||
|
||||
$first = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
expect($first)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 1])
|
||||
->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
|
||||
->and((int) $poisonBatch->fresh()->auto_payout_attempts)->toBe(1)
|
||||
->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value);
|
||||
|
||||
$second = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
expect($second)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
|
||||
->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
|
||||
->and($healthyDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
|
||||
->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value);
|
||||
});
|
||||
@@ -1,23 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\Player;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
|
||||
use App\Services\AgentSettlement\GameSettlementReversalService;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
use App\Services\Settlement\SettlementTickFinalizer;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Settlement\SettlementTickFinalizer;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
|
||||
use App\Services\AgentSettlement\GameSettlementReversalService;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -132,15 +133,63 @@ test('agent recorder allows re-settlement after share ledger reversal', function
|
||||
$item->setRelation('player', $player);
|
||||
|
||||
$recorder = app(AgentGameSettlementRecorder::class);
|
||||
$recorder->recordForTicketItem($item, 0, 'settled_lose');
|
||||
expect(countActiveShareLedgerRows($item->id))->toBe(1);
|
||||
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 1]);
|
||||
|
||||
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh());
|
||||
expect(countActiveShareLedgerRows($item->id))->toBe(0);
|
||||
$recorder->recordForTicketItem($item, 0, 'settled_lose', 1);
|
||||
expect(countActiveShareLedgerRows($item->id))->toBe(1)
|
||||
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
|
||||
|
||||
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 1);
|
||||
expect(countActiveShareLedgerRows($item->id))->toBe(0)
|
||||
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
|
||||
|
||||
$item->forceFill(['agent_settled_at' => null])->save();
|
||||
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose');
|
||||
expect(countActiveShareLedgerRows($item->id))->toBe(1);
|
||||
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose', 2);
|
||||
expect(countActiveShareLedgerRows($item->id))->toBe(1)
|
||||
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
|
||||
->and(DB::table('credit_ledger')
|
||||
->where('ref_type', 'ticket_item')
|
||||
->where('ref_id', $item->id)
|
||||
->where('reason', 'game_settlement_loss')
|
||||
->count())->toBe(2);
|
||||
|
||||
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 2);
|
||||
expect(countActiveShareLedgerRows($item->id))->toBe(0)
|
||||
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
|
||||
->and(DB::table('credit_ledger')
|
||||
->where('ref_type', 'ticket_item')
|
||||
->where('ref_id', $item->id)
|
||||
->where('reason', 'game_settlement_reversal')
|
||||
->count())->toBe(2)
|
||||
->and(DB::table('credit_ledger')
|
||||
->where('ref_type', 'ticket_item')
|
||||
->where('ref_id', $item->id)
|
||||
->where('reason', 'bet_hold_restore')
|
||||
->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('credit win re-settlement applies the same direction again for a new version', function (): void {
|
||||
$player = creditAgentPlayerForFixtures('win-re-settle');
|
||||
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 10]);
|
||||
|
||||
$credit = app(PlayerCreditService::class);
|
||||
$credit->releaseBetHold($player, 100, 99001, 1);
|
||||
$credit->applySettledWin($player, 300, 99001, 1);
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6);
|
||||
|
||||
$credit->reverseGameSettlement($player, -300, 99001, 1);
|
||||
$credit->restoreBetHoldAfterSettlementReversal($player, 100, 99001, 1);
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10);
|
||||
|
||||
$credit->releaseBetHold($player, 100, 99001, 2);
|
||||
$credit->applySettledWin($player, 300, 99001, 2);
|
||||
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6)
|
||||
->and(DB::table('credit_ledger')
|
||||
->where('ref_type', 'ticket_item')
|
||||
->where('ref_id', 99001)
|
||||
->where('reason', 'game_settlement_win')
|
||||
->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('credit player payout skips lottery wallet settle_payout txn', function (): void {
|
||||
@@ -279,7 +328,7 @@ test('settlement report show rejects inaccessible settlement period', function (
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('settlement tick finalizer marks approved batch failed when payout throws', function (): void {
|
||||
test('settlement tick finalizer keeps approved batch retryable when payout throws', function (): void {
|
||||
$player = creditAgentPlayerForFixtures('tick-fail');
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => 'FIX-FAIL-DRAW',
|
||||
@@ -363,7 +412,7 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('ticket_items')->insert([
|
||||
$orphanItemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'T-FIX-FAIL-ORPHAN',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
@@ -394,6 +443,13 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
|
||||
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($result['payout_failed'])->toBe(1)
|
||||
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Failed->value)
|
||||
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
|
||||
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed');
|
||||
});
|
||||
|
||||
DB::table('ticket_items')->where('id', $orphanItemId)->delete();
|
||||
$retry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
|
||||
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
|
||||
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value);
|
||||
});
|
||||
|
||||
@@ -1446,3 +1446,126 @@ test('ticket pending confirmation reconcile refunds when draw no longer accepts
|
||||
->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1)
|
||||
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
|
||||
});
|
||||
|
||||
test('ticket pending confirmation reconcile releases credit hold when draw no longer accepts bets', function (): void {
|
||||
$draw = ticketOpenDraw('20260511-credit-stale');
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'close_time' => now()->subMinute(),
|
||||
'draw_time' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$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' => 'native:stale-credit-hold',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'stale_credit_hold',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 1000,
|
||||
'used_credit' => 1,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'TO-PENDING-CREDIT-CLOSED',
|
||||
'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' => 3000,
|
||||
'status' => 'pending_confirm',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => 'pending-credit-on-closed-draw',
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);
|
||||
|
||||
$item = TicketItem::query()->create([
|
||||
'ticket_no' => 'TK-PENDING-CREDIT-CLOSED',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 4,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => 'straight',
|
||||
'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' => 3000,
|
||||
'risk_locked_amount' => 3000,
|
||||
'status' => 'pending_confirm',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => null,
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 1,
|
||||
'number_4d' => '1234',
|
||||
'bet_amount' => 100,
|
||||
'estimated_payout' => 3000,
|
||||
'created_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
RiskPool::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'normalized_number' => '1234',
|
||||
'total_cap_amount' => 5000,
|
||||
'locked_amount' => 3000,
|
||||
'remaining_amount' => 2000,
|
||||
'sold_out_status' => 0,
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
|
||||
->expectsOutputToContain('refunded: 1')
|
||||
->assertExitCode(0);
|
||||
|
||||
expect($order->fresh()->status)->toBe('refunded')
|
||||
->and($item->fresh()->status)->toBe('refunded')
|
||||
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0)
|
||||
->and(DB::table('credit_ledger')
|
||||
->where('owner_id', $player->id)
|
||||
->where('reason', 'bet_hold_release')
|
||||
->where('ref_type', 'ticket_order')
|
||||
->where('ref_id', $order->id)
|
||||
->count())->toBe(1)
|
||||
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
|
||||
|
||||
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
|
||||
->assertExitCode(0);
|
||||
|
||||
expect(DB::table('credit_ledger')
|
||||
->where('owner_id', $player->id)
|
||||
->where('reason', 'bet_hold_release')
|
||||
->where('ref_type', 'ticket_order')
|
||||
->where('ref_id', $order->id)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
@@ -4,12 +4,13 @@ use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\PlayerWallet;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
fakeWalletApiDns();
|
||||
$this->seed(CurrencySeeder::class);
|
||||
config(['lottery.main_site.wallet_api_url' => null]);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
fakeWalletApiDns();
|
||||
config(['lottery.main_site.wallet_api_url' => null]);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
@@ -139,6 +140,38 @@ test('transfer in main site explicit failure returns 1009 and marks order failed
|
||||
->and(WalletTxn::query()->where('player_id', $player->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('transfer in rejects private dns answer before sending wallet bearer request', function (): void {
|
||||
fakeWalletApiDns([
|
||||
'private-debit.test' => ['10.0.0.8'],
|
||||
], []);
|
||||
Http::preventStrayRequests();
|
||||
config(['lottery.main_site.wallet_api_url' => 'https://private-debit.test']);
|
||||
config(['lottery.main_site.wallet_debit_path' => 'debit']);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'private-dns-in',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$key = 'private-dns-in-'.uniqid('', true);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 500,
|
||||
'currency' => 'NPR',
|
||||
'idempotent_key' => $key,
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::WalletExternalRejected->value);
|
||||
|
||||
expect(TransferOrder::query()->where('idempotent_key', $key)->value('status'))->toBe('failed');
|
||||
Http::assertSentCount(0);
|
||||
});
|
||||
|
||||
test('transfer in main site timeout returns 1002 and pending_reconcile', function (): void {
|
||||
Http::fake([
|
||||
'timeout-debit.test/*' => Http::response([], 504),
|
||||
|
||||
@@ -14,6 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
fakeWalletApiDns();
|
||||
config(['lottery.main_site.wallet_api_url' => null]);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
|
||||
@@ -2,9 +2,24 @@
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentProfile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Support\SuperAdminAccount;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Events\OddsUpdateBroadcast;
|
||||
use App\Events\PlayToggleBroadcast;
|
||||
use App\Events\RiskSoldOutBroadcast;
|
||||
use App\Events\RiskWarningBroadcast;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use App\Events\JackpotBurstBroadcast;
|
||||
use App\Events\BalanceUpdateBroadcast;
|
||||
use App\Events\DrawCountdownBroadcast;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Contracts\WalletApiDnsResolver;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use App\Events\PlayCatalogUpdatedBroadcast;
|
||||
use App\Events\DrawResultPublishedBroadcast;
|
||||
use App\Support\Integration\WalletApiRequestGuard;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
/*
|
||||
@@ -53,8 +68,8 @@ expect()->extend('toBeOne', function () {
|
||||
/** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */
|
||||
function grantSuperAdminRole(AdminUser $admin): void
|
||||
{
|
||||
\App\Support\PlatformSystemRoles::ensureSuperAdminRole();
|
||||
\App\Support\SuperAdminAccount::assign($admin);
|
||||
PlatformSystemRoles::ensureSuperAdminRole();
|
||||
SuperAdminAccount::assign($admin);
|
||||
}
|
||||
|
||||
/** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */
|
||||
@@ -76,6 +91,37 @@ function agentChildPayload(array $overrides = []): array
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为钱包 HTTP 测试提供离线 DNS,避免测试依赖机器或公网解析状态。
|
||||
*
|
||||
* @param array<string, list<string>> $recordsByHost
|
||||
* @param list<string> $defaultRecords
|
||||
*/
|
||||
function fakeWalletApiDns(
|
||||
array $recordsByHost = [],
|
||||
array $defaultRecords = ['93.184.216.34'],
|
||||
): void {
|
||||
app()->instance(WalletApiDnsResolver::class, new class($recordsByHost, $defaultRecords) implements WalletApiDnsResolver
|
||||
{
|
||||
/**
|
||||
* @param array<string, list<string>> $recordsByHost
|
||||
* @param list<string> $defaultRecords
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $recordsByHost,
|
||||
private readonly array $defaultRecords,
|
||||
) {}
|
||||
|
||||
public function resolveAll(string $hostname): array
|
||||
{
|
||||
return $this->recordsByHost[$hostname] ?? $this->defaultRecords;
|
||||
}
|
||||
});
|
||||
|
||||
// Guard 可能已在当前测试中解析过,清除后确保使用刚绑定的 DNS resolver。
|
||||
app()->forgetInstance(WalletApiRequestGuard::class);
|
||||
}
|
||||
|
||||
function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
|
||||
{
|
||||
DB::table('admin_user_agents')->updateOrInsert(
|
||||
@@ -96,7 +142,7 @@ function ensureRootAgentProfileSeeded(): void
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Models\AgentProfile::query()->updateOrCreate(
|
||||
AgentProfile::query()->updateOrCreate(
|
||||
['agent_node_id' => (int) $rootId],
|
||||
[
|
||||
'total_share_rate' => 100,
|
||||
@@ -154,15 +200,15 @@ function ensureAdminActionCatalogSeeded(): void
|
||||
function allBroadcastEvents(): array
|
||||
{
|
||||
return [
|
||||
\App\Events\BalanceUpdateBroadcast::class,
|
||||
\App\Events\DrawCountdownBroadcast::class,
|
||||
\App\Events\DrawResultPublishedBroadcast::class,
|
||||
\App\Events\DrawStatusChangeBroadcast::class,
|
||||
\App\Events\JackpotBurstBroadcast::class,
|
||||
\App\Events\OddsUpdateBroadcast::class,
|
||||
\App\Events\PlayCatalogUpdatedBroadcast::class,
|
||||
\App\Events\PlayToggleBroadcast::class,
|
||||
\App\Events\RiskSoldOutBroadcast::class,
|
||||
\App\Events\RiskWarningBroadcast::class,
|
||||
BalanceUpdateBroadcast::class,
|
||||
DrawCountdownBroadcast::class,
|
||||
DrawResultPublishedBroadcast::class,
|
||||
DrawStatusChangeBroadcast::class,
|
||||
JackpotBurstBroadcast::class,
|
||||
OddsUpdateBroadcast::class,
|
||||
PlayCatalogUpdatedBroadcast::class,
|
||||
PlayToggleBroadcast::class,
|
||||
RiskSoldOutBroadcast::class,
|
||||
RiskWarningBroadcast::class,
|
||||
];
|
||||
}
|
||||
|
||||
74
tests/Unit/WalletApiRequestGuardTest.php
Normal file
74
tests/Unit/WalletApiRequestGuardTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use App\Support\Integration\WalletApiRequestGuard;
|
||||
use App\Support\Integration\WalletApiUrlSanitizer;
|
||||
|
||||
test('wallet api sanitizer rejects private and reserved literal addresses', function (string $url): void {
|
||||
expect(WalletApiUrlSanitizer::normalizeAndValidate($url))->toBeNull();
|
||||
})->with([
|
||||
'ipv4 loopback' => 'https://127.0.0.1',
|
||||
'ipv4 shortened notation' => 'https://127.1',
|
||||
'ipv4 decimal integer notation' => 'https://2130706433',
|
||||
'ipv4 hexadecimal notation' => 'https://0x7f000001',
|
||||
'ipv4 octal notation' => 'https://0177.0.0.1',
|
||||
'ipv4 private' => 'https://10.0.0.1',
|
||||
'ipv4 link local' => 'https://169.254.169.254',
|
||||
'ipv4 cgnat' => 'https://100.64.0.1',
|
||||
'ipv4 documentation' => 'https://192.0.2.1',
|
||||
'ipv6 loopback' => 'https://[::1]',
|
||||
'ipv6 unique local' => 'https://[fd00::1]',
|
||||
'ipv6 link local' => 'https://[fe80::1]',
|
||||
'ipv6 nat64 private target' => 'https://[64:ff9b::a00:1]',
|
||||
'ipv6 protocol assignment' => 'https://[2001::1]',
|
||||
'ipv6 documentation' => 'https://[2001:db8::1]',
|
||||
]);
|
||||
|
||||
test('wallet api guard rejects hostname when any dns answer is non-public', function (): void {
|
||||
fakeWalletApiDns([
|
||||
'wallet.example.test' => ['93.184.216.34', '10.0.0.8'],
|
||||
], []);
|
||||
|
||||
expect(app(WalletApiRequestGuard::class)->guard('https://wallet.example.test', 10))
|
||||
->toBeNull();
|
||||
});
|
||||
|
||||
test('wallet api guard rejects unresolved hostname', function (): void {
|
||||
fakeWalletApiDns([], []);
|
||||
|
||||
expect(app(WalletApiRequestGuard::class)->guard('https://missing.example.test', 10))
|
||||
->toBeNull();
|
||||
});
|
||||
|
||||
test('wallet api guard accepts public ipv4 and ipv6 dns answers and bounds connect timeout', function (): void {
|
||||
fakeWalletApiDns([
|
||||
'wallet.example.test' => ['93.184.216.34', '2606:4700:4700::1111'],
|
||||
], []);
|
||||
|
||||
$endpoint = app(WalletApiRequestGuard::class)->guard('https://wallet.example.test:8443', 120);
|
||||
|
||||
expect($endpoint)->not->toBeNull()
|
||||
->and($endpoint?->baseUrl)->toBe('https://wallet.example.test:8443')
|
||||
->and($endpoint?->port)->toBe(8443)
|
||||
->and($endpoint?->pinnedIp)->toBe('93.184.216.34')
|
||||
->and($endpoint?->totalTimeoutSeconds)->toBe(120)
|
||||
->and($endpoint?->connectTimeoutSeconds)->toBe(5);
|
||||
|
||||
$pending = $endpoint?->request(['Authorization' => 'Bearer secret']);
|
||||
$optionsProperty = new ReflectionProperty(PendingRequest::class, 'options');
|
||||
$options = $optionsProperty->getValue($pending);
|
||||
|
||||
expect($options['allow_redirects'] ?? null)->toBeFalse()
|
||||
->and($options['proxy'] ?? null)->toBe('')
|
||||
->and($options['timeout'] ?? null)->toBe(120)
|
||||
->and($options['connect_timeout'] ?? null)->toBe(5)
|
||||
->and($options['curl'][CURLOPT_RESOLVE] ?? null)
|
||||
->toBe(['wallet.example.test:8443:93.184.216.34']);
|
||||
});
|
||||
|
||||
test('wallet api sanitizer accepts normal public literals', function (): void {
|
||||
expect(WalletApiUrlSanitizer::normalizeAndValidate('https://93.184.216.34'))
|
||||
->toBe('https://93.184.216.34')
|
||||
->and(WalletApiUrlSanitizer::normalizeAndValidate('https://[2606:4700:4700::1111]'))
|
||||
->toBe('https://[2606:4700:4700::1111]');
|
||||
});
|
||||
Reference in New Issue
Block a user