feat: 更新玩法配置管理,简化字段并增强功能
- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。 - 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。 - 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
45
tests/Feature/AdminApiAuditMiddlewareTest.php
Normal file
45
tests/Feature/AdminApiAuditMiddlewareTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('admin api audit middleware records draw reopen', function (): void {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'audit_reopen_admin',
|
||||
'name' => 'Audit Reopen',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260525-099',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 99,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'settle_version' => 0,
|
||||
]);
|
||||
|
||||
$before = AuditLog::query()->count();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/reopen", ['reason' => 'audit test'])
|
||||
->assertOk();
|
||||
|
||||
expect(AuditLog::query()->count())->toBe($before + 1);
|
||||
|
||||
/** @var AuditLog $row */
|
||||
$row = AuditLog::query()->latest('id')->first();
|
||||
expect($row->module_code)->toBe('draw')
|
||||
->and($row->action_code)->toBe('reopen')
|
||||
->and($row->operator_id)->toBe($admin->id);
|
||||
});
|
||||
@@ -90,9 +90,7 @@ test('enabling currency as bettable bootstraps odds items and jackpot pool', fun
|
||||
'category' => '4d',
|
||||
'dimension' => 4,
|
||||
'bet_mode' => 'single',
|
||||
'display_name_zh' => '直选',
|
||||
'display_name_en' => 'Straight',
|
||||
'display_name_ne' => 'सिधा',
|
||||
'display_name' => '直选',
|
||||
'is_enabled' => true,
|
||||
'sort_order' => 10,
|
||||
'supports_multi_number' => false,
|
||||
|
||||
105
tests/Feature/AdminDashboardAnalyticsApiTest.php
Normal file
105
tests/Feature/AdminDashboardAnalyticsApiTest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('dashboard analytics returns summary trend and play breakdown for period', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'da-p1',
|
||||
'username' => 'da_u1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260510-001',
|
||||
'business_date' => '2026-05-10',
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'start_time' => now()->subDay(),
|
||||
'close_time' => now()->subDay(),
|
||||
'draw_time' => now()->subDay(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'ORD-DA-1',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 8_000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 8_000,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'settled',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => null,
|
||||
]);
|
||||
|
||||
TicketItem::query()->create([
|
||||
'ticket_no' => 'TK-DA-1',
|
||||
'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' => null,
|
||||
'unit_bet_amount' => 8_000,
|
||||
'total_bet_amount' => 8_000,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 8_000,
|
||||
'odds_snapshot_json' => null,
|
||||
'rule_snapshot_json' => null,
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'won',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'win_amount' => 2_000,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'dash_analytics_admin',
|
||||
'name' => 'Dash Analytics QA',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/dashboard/analytics?period=last_30_days&metric=overview')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.summary.total_bet_minor', 8_000)
|
||||
->assertJsonPath('data.summary.total_payout_minor', 2_000)
|
||||
->assertJsonPath('data.summary.approx_house_gross_minor', 6_000)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'daily_series',
|
||||
'play_breakdown',
|
||||
'chart_meta' => ['truncated', 'span_days'],
|
||||
],
|
||||
]);
|
||||
});
|
||||
103
tests/Feature/AdminDashboardAnalyticsLifetimeTest.php
Normal file
103
tests/Feature/AdminDashboardAnalyticsLifetimeTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Services\Admin\AdminReportQueryService;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('resolve dashboard period lifetime returns associative bounds without error', function (): void {
|
||||
$service = app(AdminReportQueryService::class);
|
||||
|
||||
$empty = $service->resolveDashboardPeriod('lifetime', null, null);
|
||||
expect($empty)->toHaveKeys(['date_from', 'date_to'])
|
||||
->and($empty['date_from'])->toBeString()
|
||||
->and($empty['date_to'])->toBeString();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'lt-p1',
|
||||
'username' => 'lt_u1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260101-001',
|
||||
'business_date' => '2026-01-01',
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'start_time' => now()->subMonths(2),
|
||||
'close_time' => now()->subMonths(2),
|
||||
'draw_time' => now()->subMonths(2),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$drawLate = Draw::query()->create([
|
||||
'draw_no' => '20260520-001',
|
||||
'business_date' => '2026-05-20',
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'start_time' => now()->subDay(),
|
||||
'close_time' => now()->subDay(),
|
||||
'draw_time' => now()->subDay(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
TicketOrder::query()->create([
|
||||
'order_no' => 'ORD-LT-1',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawLate->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 1_000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 1_000,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'settled',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => null,
|
||||
]);
|
||||
|
||||
$range = $service->resolveDashboardPeriod('lifetime', null, null);
|
||||
expect($range['date_from'])->toBe('2026-05-20')
|
||||
->and($range['date_to'])->toBe('2026-05-20');
|
||||
});
|
||||
|
||||
test('dashboard analytics lifetime period returns ok via http', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'lt_dash_admin',
|
||||
'name' => 'LT Dash',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/dashboard/analytics?period=lifetime&metric=overview')
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'date_from',
|
||||
'date_to',
|
||||
'summary',
|
||||
'daily_series',
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -61,6 +61,8 @@ test('admin dashboard aggregates hall finance and risk for super admin', functio
|
||||
->assertJsonPath('data.resolved_draw.id', $draw->id)
|
||||
->assertJsonPath('data.capabilities.draw_finance_risk', true)
|
||||
->assertJsonPath('data.capabilities.wallet_transfer_view', true)
|
||||
->assertJsonPath('data.today_finance.business_date', now()->toDateString())
|
||||
->assertJsonPath('data.today_finance.total_bet_minor', 0)
|
||||
->assertJsonPath('data.finance.draw_id', $draw->id)
|
||||
->assertJsonPath('data.draw.result_batch_counts.total', 0)
|
||||
->assertJsonPath('data.risk.locked_amount', 200_100)
|
||||
|
||||
75
tests/Feature/AdminPermissionGranularityTest.php
Normal file
75
tests/Feature/AdminPermissionGranularityTest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Database\Seeders\AdminRbacAndUserSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('config mutate resources require domain manage slugs only', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$playStore = resourceLegacySlugs('admin.config.play-versions.store');
|
||||
expect($playStore)->toBe(['prd.play_switch.manage']);
|
||||
|
||||
$oddsStore = resourceLegacySlugs('admin.config.odds-versions.store');
|
||||
expect($oddsStore)->toContain('prd.odds.manage')
|
||||
->and($oddsStore)->not->toContain('prd.play_switch.manage');
|
||||
|
||||
$riskCapStore = resourceLegacySlugs('admin.config.risk-cap-versions.store');
|
||||
expect($riskCapStore)->toBe(['prd.risk_cap.manage']);
|
||||
});
|
||||
|
||||
test('user with report view only cannot create report export job', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'report_view_only',
|
||||
'name' => 'Tester',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create(['slug' => 'report_view_only', 'name' => 'Report view only']);
|
||||
$role->syncLegacyPermissionSlugs(['prd.report.view', 'prd.dashboard.view']);
|
||||
|
||||
$siteId = AdminUser::defaultAdminSiteId();
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
|
||||
]);
|
||||
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/reports/daily-profit')
|
||||
->assertOk();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/report-jobs', [
|
||||
'report_type' => 'daily_profit_summary',
|
||||
'export_format' => 'csv',
|
||||
'parameters' => [
|
||||
'date_from' => '2026-05-01',
|
||||
'date_to' => '2026-05-07',
|
||||
],
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
/** @return list<string> */
|
||||
function resourceLegacySlugs(string $code): array
|
||||
{
|
||||
$resource = collect(AdminAuthorizationRegistry::resourceDefinitions())
|
||||
->firstWhere('code', $code);
|
||||
|
||||
expect($resource)->not->toBeNull();
|
||||
|
||||
return $resource['legacy_permission_slugs'] ?? [];
|
||||
}
|
||||
@@ -52,7 +52,7 @@ test('finance role with report legacy can access report jobs after rbac seed', f
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('report api resources only bind service.report.view', function (): void {
|
||||
test('report read api resources bind service.report.view only', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
@@ -63,13 +63,28 @@ test('report api resources only bind service.report.view', function (): void {
|
||||
];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$bindings = DB::table('admin_api_resources as ar')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
|
||||
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
|
||||
->where('ar.code', $code)
|
||||
->pluck('ma.permission_code')
|
||||
->all();
|
||||
|
||||
$bindings = bindingsForResource($code);
|
||||
expect($bindings)->toBe(['service.report.view']);
|
||||
}
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
/** @return list<string> */
|
||||
function bindingsForResource(string $code): array
|
||||
{
|
||||
return DB::table('admin_api_resources as ar')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
|
||||
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
|
||||
->where('ar.code', $code)
|
||||
->orderBy('ma.permission_code')
|
||||
->pluck('ma.permission_code')
|
||||
->all();
|
||||
}
|
||||
|
||||
119
tests/Feature/AdminReportPlatformLifetimeTotalsTest.php
Normal file
119
tests/Feature/AdminReportPlatformLifetimeTotalsTest.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Services\Admin\AdminReportQueryService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('platform lifetime totals aggregate all draws with daily profit口径', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'plt-p1',
|
||||
'username' => 'plt_u1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$drawA = Draw::query()->create([
|
||||
'draw_no' => '20260501-001',
|
||||
'business_date' => '2026-05-01',
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'start_time' => now()->subDays(2),
|
||||
'close_time' => now()->subDays(2)->addHour(),
|
||||
'draw_time' => now()->subDays(2)->addHours(2),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$drawB = Draw::query()->create([
|
||||
'draw_no' => '20260502-001',
|
||||
'business_date' => '2026-05-02',
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'start_time' => now()->subDay(),
|
||||
'close_time' => now()->subDay()->addHour(),
|
||||
'draw_time' => now()->subDay()->addHours(2),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$orderA = TicketOrder::query()->create([
|
||||
'order_no' => 'ORD-A',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawA->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 10_000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 10_000,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'settled',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => null,
|
||||
]);
|
||||
|
||||
TicketOrder::query()->create([
|
||||
'order_no' => 'ORD-B',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawB->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 5_000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 5_000,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'settled',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => null,
|
||||
]);
|
||||
|
||||
TicketItem::query()->create([
|
||||
'ticket_no' => 'TK-PLT-1',
|
||||
'order_id' => $orderA->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawA->id,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 4,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => null,
|
||||
'unit_bet_amount' => 10_000,
|
||||
'total_bet_amount' => 10_000,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 10_000,
|
||||
'odds_snapshot_json' => null,
|
||||
'rule_snapshot_json' => null,
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'won',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'win_amount' => 3_000,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => now(),
|
||||
]);
|
||||
|
||||
$totals = app(AdminReportQueryService::class)->platformLifetimeTotals();
|
||||
|
||||
expect($totals['total_bet_minor'])->toBe(15_000)
|
||||
->and($totals['total_payout_minor'])->toBe(3_000)
|
||||
->and($totals['approx_house_gross_minor'])->toBe(12_000)
|
||||
->and($totals['draw_count'])->toBe(2)
|
||||
->and($totals['business_day_count'])->toBe(2)
|
||||
->and($totals['date_from'])->toBe('2026-05-01')
|
||||
->and($totals['date_to'])->toBe('2026-05-02')
|
||||
->and($totals['currency_code'])->toBe('NPR');
|
||||
});
|
||||
@@ -3,12 +3,39 @@
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\JackpotPayoutLog;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Events\JackpotBurstBroadcast;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
$this->seed(OperationalConfigV1Seeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
function mintSettlementAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
@@ -23,6 +50,44 @@ function mintSettlementAdminToken(): string
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
function mintRiskOperatorToken(): string
|
||||
{
|
||||
$now = now();
|
||||
DB::table('admin_roles')->updateOrInsert(
|
||||
['slug' => 'risk_operator'],
|
||||
['name' => 'Risk', 'code' => 'risk_operator', 'created_at' => $now, 'updated_at' => $now],
|
||||
);
|
||||
$roleId = (int) DB::table('admin_roles')->where('slug', 'risk_operator')->value('id');
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'risk_jp_admin',
|
||||
'name' => 'Risk JP',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
$manageMenuActionId = (int) DB::table('admin_menu_actions')
|
||||
->where('permission_code', 'config.jackpot.manage')
|
||||
->value('id');
|
||||
if ($manageMenuActionId > 0) {
|
||||
DB::table('admin_role_menu_actions')->updateOrInsert(
|
||||
['role_id' => $roleId, 'menu_action_id' => $manageMenuActionId],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('admin settlement batches index is authenticated', function (): void {
|
||||
$this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized();
|
||||
});
|
||||
@@ -42,22 +107,27 @@ test('admin jackpot pools index returns rows', function (): void {
|
||||
->assertJsonPath('data.items.0.combo_trigger_play_codes', []);
|
||||
});
|
||||
|
||||
test('admin can update jackpot combo trigger and manually burst pool', function (): void {
|
||||
test('admin can update jackpot combo trigger', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$pool->forceFill([
|
||||
'current_amount' => 1000,
|
||||
'contribution_rate' => '0.01',
|
||||
'trigger_threshold' => 1000,
|
||||
'payout_rate' => '0.5',
|
||||
'force_trigger_draw_gap' => 10,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
])->save();
|
||||
$token = mintSettlementAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [
|
||||
'combo_trigger_play_codes' => ['straight', 'ibox'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.combo_trigger_play_codes.0', 'straight')
|
||||
->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox');
|
||||
});
|
||||
|
||||
test('risk operator cannot manually burst jackpot', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$pool->forceFill(['current_amount' => 1000, 'status' => 1])->save();
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260518-001',
|
||||
'draw_no' => '20260518-099',
|
||||
'business_date' => '2026-05-18',
|
||||
'sequence_no' => 1,
|
||||
'sequence_no' => 99,
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'start_time' => now()->subHours(2),
|
||||
'close_time' => now()->subHour(),
|
||||
@@ -69,22 +139,263 @@ test('admin can update jackpot combo trigger and manually burst pool', function
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$token = mintSettlementAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [
|
||||
'combo_trigger_play_codes' => ['straight', 'ibox'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.combo_trigger_play_codes.0', 'straight')
|
||||
->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox');
|
||||
$token = mintRiskOperatorToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
|
||||
'draw_id' => $draw->id,
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('super admin manual burst allocates jackpot to first prize winners after settlement', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$pool->forceFill([
|
||||
'current_amount' => 10_000,
|
||||
'contribution_rate' => '0',
|
||||
'trigger_threshold' => 999_999_999,
|
||||
'payout_rate' => '0.5000',
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
])->save();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'manual-burst-p1',
|
||||
'username' => 'manual_burst_p1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 5_000_000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260518-010',
|
||||
'business_date' => '2026-05-18',
|
||||
'sequence_no' => 10,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(5),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => (string) $draw->draw_no,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'manual-burst-bet-1',
|
||||
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = $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' => $num,
|
||||
'suffix_3d' => substr($num, -3),
|
||||
'suffix_2d' => substr($num, -2),
|
||||
'head_digit' => (int) substr($num, 0, 1),
|
||||
'tail_digit' => (int) substr($num, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect((int) $item->jackpot_win_amount)->toBe(0);
|
||||
|
||||
$admin = AdminUser::query()->where('username', 'settlement_admin')->first();
|
||||
if ($admin === null) {
|
||||
mintSettlementAdminToken();
|
||||
$admin = AdminUser::query()->where('username', 'settlement_admin')->firstOrFail();
|
||||
} else {
|
||||
grantSuperAdminRole($admin);
|
||||
}
|
||||
|
||||
$settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin);
|
||||
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
|
||||
|
||||
Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]);
|
||||
config([
|
||||
'broadcasting.default' => 'reverb',
|
||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||
]);
|
||||
|
||||
$token = $admin->createToken('burst', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
|
||||
'draw_id' => $draw->id,
|
||||
'amount' => 400,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.burst_amount', 400)
|
||||
->assertJsonPath('data.current_amount', 600);
|
||||
->assertJsonPath('data.burst_amount', 5000)
|
||||
->assertJsonPath('data.current_amount', 5000)
|
||||
->assertJsonPath('data.winner_count', 1);
|
||||
|
||||
$item->refresh();
|
||||
expect((int) $item->jackpot_win_amount)->toBe(5000);
|
||||
|
||||
$log = JackpotPayoutLog::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect($log->trigger_type)->toBe('manual')
|
||||
->and($log->winner_count)->toBe(1)
|
||||
->and((int) $log->total_payout_amount)->toBe(5000);
|
||||
|
||||
expect(WalletTxn::query()->where('biz_type', 'jackpot_manual_payout')->count())->toBe(1);
|
||||
|
||||
Event::assertDispatched(
|
||||
JackpotBurstBroadcast::class,
|
||||
fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id
|
||||
&& $event->triggerType === 'manual'
|
||||
&& $event->totalPayoutAmount === 5000
|
||||
&& $event->winnerCount === 1
|
||||
&& $event->firstPrizeNumber === '1234',
|
||||
);
|
||||
});
|
||||
|
||||
test('manual burst broadcast includes published first prize number', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$pool->forceFill(['current_amount' => 500, 'status' => 1, 'payout_rate' => '1'])->save();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'manual-burst-p2',
|
||||
'username' => 'manual_burst_p2',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 1_000_000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260518-002',
|
||||
'business_date' => '2026-05-18',
|
||||
'sequence_no' => 2,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(5),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => (string) $draw->draw_no,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'manual-burst-bet-2',
|
||||
'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$resultBatch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = $slot['prize_type'] === 'first' ? '1234' : '5678';
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $resultBatch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => substr($num, -3),
|
||||
'suffix_2d' => substr($num, -2),
|
||||
'head_digit' => (int) substr($num, 0, 1),
|
||||
'tail_digit' => (int) substr($num, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
'result_source' => 'rng',
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'manual_burst_admin2',
|
||||
'name' => 'Burst Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin);
|
||||
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
|
||||
|
||||
Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]);
|
||||
config([
|
||||
'broadcasting.default' => 'reverb',
|
||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||
]);
|
||||
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [
|
||||
'draw_id' => $draw->id,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
Event::assertDispatched(
|
||||
JackpotBurstBroadcast::class,
|
||||
fn (JackpotBurstBroadcast $event): bool => $event->firstPrizeNumber === '1234',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Services\Draw\DrawTickService;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use App\Services\Draw\DrawPlannerService;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -747,9 +748,70 @@ test('lottery hall-countdown dispatches draw.countdown when using reverb connect
|
||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||
]);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260509-001',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 1,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(5),
|
||||
'close_time' => now()->addMinutes(20),
|
||||
'draw_time' => now()->addMinutes(25),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->artisan('lottery:hall-countdown')->assertSuccessful();
|
||||
|
||||
Event::assertDispatched(DrawCountdownBroadcast::class);
|
||||
Event::assertDispatched(
|
||||
DrawCountdownBroadcast::class,
|
||||
fn (DrawCountdownBroadcast $event): bool => is_array($event->data) && isset($event->data['draw_no']),
|
||||
);
|
||||
});
|
||||
|
||||
test('hall snapshot switches to next bettable draw when cooldown ended', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC'));
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260510-001',
|
||||
'business_date' => '2026-05-10',
|
||||
'sequence_no' => 1,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => now()->subHours(3),
|
||||
'close_time' => now()->subHours(2),
|
||||
'draw_time' => now()->subHours(2),
|
||||
'cooling_end_time' => now()->subMinutes(5),
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$next = Draw::query()->create([
|
||||
'draw_no' => '20260510-002',
|
||||
'business_date' => '2026-05-10',
|
||||
'sequence_no' => 2,
|
||||
'status' => DrawStatus::Pending->value,
|
||||
'start_time' => now()->subMinutes(1),
|
||||
'close_time' => now()->addMinutes(20),
|
||||
'draw_time' => now()->addMinutes(25),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc());
|
||||
expect($target)->not->toBeNull()
|
||||
->and($target->draw_no)->toBe('20260510-002');
|
||||
|
||||
$payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc());
|
||||
expect($payload['status'] ?? null)->toBe('open');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void {
|
||||
|
||||
@@ -79,6 +79,17 @@ function jackpotOpenDraw(string $drawNo): Draw
|
||||
]);
|
||||
}
|
||||
|
||||
/** 迁移已 seed 默认 NPR 池,测试内用 upsert 避免 UNIQUE(currency_code) 冲突 */
|
||||
function jackpotUpsertPool(array $attrs): JackpotPool
|
||||
{
|
||||
$currencyCode = (string) ($attrs['currency_code'] ?? 'NPR');
|
||||
|
||||
return JackpotPool::query()->updateOrCreate(
|
||||
['currency_code' => $currencyCode],
|
||||
$attrs,
|
||||
);
|
||||
}
|
||||
|
||||
function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
|
||||
{
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
@@ -115,7 +126,7 @@ function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void
|
||||
}
|
||||
|
||||
test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
jackpotUpsertPool([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => '0.1000',
|
||||
@@ -238,7 +249,7 @@ test('jackpot contributes on place and bursts on settle for first-prize straight
|
||||
});
|
||||
|
||||
test('jackpot contribution respects switch and minimum bet threshold', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
jackpotUpsertPool([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => '0.1000',
|
||||
@@ -288,7 +299,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f
|
||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||
]);
|
||||
|
||||
JackpotPool::query()->create([
|
||||
jackpotUpsertPool([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 50_000,
|
||||
'contribution_rate' => '0.0000',
|
||||
@@ -336,7 +347,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f
|
||||
});
|
||||
|
||||
test('jackpot splits burst payout between multiple winners by bet amount', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
jackpotUpsertPool([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 90_000,
|
||||
'contribution_rate' => '0.0000',
|
||||
@@ -413,7 +424,7 @@ test('jackpot summary and result payload expose pool amount and draw gap', funct
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
JackpotPool::query()->create([
|
||||
jackpotUpsertPool([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 123_456,
|
||||
'contribution_rate' => '0.0100',
|
||||
|
||||
@@ -80,9 +80,7 @@ test('§12.6 published play limits are visible on public effective catalog witho
|
||||
'category' => $t->category,
|
||||
'dimension' => $t->dimension,
|
||||
'bet_mode' => $t->bet_mode,
|
||||
'display_name_zh' => $t->display_name_zh ?? $t->play_code,
|
||||
'display_name_en' => $t->display_name_en,
|
||||
'display_name_ne' => $t->display_name_ne,
|
||||
'display_name' => $t->display_name ?? $t->play_code,
|
||||
'is_enabled' => true,
|
||||
'min_bet_amount' => 777,
|
||||
'max_bet_amount' => 400_000_000,
|
||||
@@ -271,9 +269,7 @@ test('§12.6 published play config controls master_enabled on public catalog wit
|
||||
'category' => $r['category'],
|
||||
'dimension' => $r['dimension'],
|
||||
'bet_mode' => $r['bet_mode'],
|
||||
'display_name_zh' => $r['display_name_zh'],
|
||||
'display_name_en' => $r['display_name_en'],
|
||||
'display_name_ne' => $r['display_name_ne'],
|
||||
'display_name' => $r['display_name'],
|
||||
'is_enabled' => $r['is_enabled'],
|
||||
'min_bet_amount' => (int) $r['min_bet_amount'],
|
||||
'max_bet_amount' => (int) $r['max_bet_amount'],
|
||||
@@ -381,9 +377,7 @@ test('§5 play_config publish is audited', function (): void {
|
||||
'category' => $t->category,
|
||||
'dimension' => $t->dimension,
|
||||
'bet_mode' => $t->bet_mode,
|
||||
'display_name_zh' => $t->display_name_zh ?? $t->play_code,
|
||||
'display_name_en' => $t->display_name_en,
|
||||
'display_name_ne' => $t->display_name_ne,
|
||||
'display_name' => $t->display_name ?? $t->play_code,
|
||||
'is_enabled' => true,
|
||||
'min_bet_amount' => 100,
|
||||
'max_bet_amount' => 500_000_000,
|
||||
@@ -404,6 +398,35 @@ test('§5 play_config publish is audited', function (): void {
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
test('play type patch toggles active config and broadcasts instantly', function (): void {
|
||||
Event::fake([PlayToggleBroadcast::class]);
|
||||
config([
|
||||
'broadcasting.default' => 'reverb',
|
||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||
]);
|
||||
|
||||
$token = acceptanceMintAdminToken();
|
||||
$auth = ['Authorization' => 'Bearer '.$token];
|
||||
|
||||
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.play_code', 'big')
|
||||
->assertJsonPath('data.is_enabled', false);
|
||||
|
||||
Event::assertDispatched(
|
||||
PlayToggleBroadcast::class,
|
||||
fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false,
|
||||
);
|
||||
|
||||
expect(
|
||||
AuditLog::query()
|
||||
->where('module_code', 'play_config')
|
||||
->where('action_code', 'toggle_active')
|
||||
->where('target_id', 'big')
|
||||
->exists(),
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
test('§9 play_config publish broadcasts changed play toggles', function (): void {
|
||||
Event::fake([PlayToggleBroadcast::class]);
|
||||
config([
|
||||
|
||||
@@ -78,9 +78,7 @@ test('admin play config draft publish flow', function (): void {
|
||||
'category' => $t->category,
|
||||
'dimension' => $t->dimension,
|
||||
'bet_mode' => $t->bet_mode,
|
||||
'display_name_zh' => $t->display_name_zh ?? $t->play_code,
|
||||
'display_name_en' => $t->display_name_en,
|
||||
'display_name_ne' => $t->display_name_ne,
|
||||
'display_name' => $t->display_name ?? $t->play_code,
|
||||
'is_enabled' => true,
|
||||
'min_bet_amount' => 200,
|
||||
'max_bet_amount' => 400_000_000,
|
||||
|
||||
133
tests/Feature/PerformanceAcceptanceTest.php
Normal file
133
tests/Feature/PerformanceAcceptanceTest.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* PRD §17.2 可自动化子集(不含 k6 与万级结算压测)。
|
||||
*/
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\PlayerWallet;
|
||||
use Carbon\Carbon;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use App\Services\Draw\DrawPlannerService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
$this->seed(OperationalConfigV1Seeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('draw planner schedules five minute draw_time gaps', function (): void {
|
||||
config([
|
||||
'lottery.draw.timezone' => 'UTC',
|
||||
'lottery.draw.interval_minutes' => 5,
|
||||
'lottery.draw.buffer_draws_ahead' => 12,
|
||||
]);
|
||||
|
||||
$fixed = Carbon::parse('2026-05-25 00:00:00', 'UTC');
|
||||
app(DrawPlannerService::class)->ensureBuffer($fixed);
|
||||
|
||||
$times = Draw::query()
|
||||
->whereNotNull('draw_time')
|
||||
->orderBy('draw_time')
|
||||
->limit(13)
|
||||
->pluck('draw_time')
|
||||
->map(fn ($t) => Carbon::parse($t)->utc())
|
||||
->all();
|
||||
|
||||
expect(count($times))->toBeGreaterThanOrEqual(2);
|
||||
|
||||
for ($i = 1; $i < count($times); $i++) {
|
||||
$delta = (int) $times[$i]->diffInSeconds($times[$i - 1], absolute: true);
|
||||
expect($delta)->toBe(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('ticket place rejects bet when draw is closing', function (): void {
|
||||
$player = perfPlayer();
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260525-001',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 1,
|
||||
'status' => DrawStatus::Closing->value,
|
||||
'start_time' => now()->subMinutes(10),
|
||||
'close_time' => now()->subMinute(),
|
||||
'draw_time' => now()->addMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260525-001',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'perf-sealed',
|
||||
'lines' => [['number' => '1234', 'play_code' => 'big', 'amount' => 10]],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::DrawClosed->value);
|
||||
});
|
||||
|
||||
test('ticket place rejects bet when close_time passed but status still open', function (): void {
|
||||
$player = perfPlayer();
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260525-002',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 2,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(10),
|
||||
'close_time' => now()->subSecond(),
|
||||
'draw_time' => now()->addMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260525-002',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'perf-close-time',
|
||||
'lines' => [['number' => '5678', 'play_code' => 'big', 'amount' => 10]],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::DrawClosed->value);
|
||||
});
|
||||
|
||||
function perfPlayer(): Player
|
||||
{
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'perf-'.$uniq,
|
||||
'username' => 'perf_'.$uniq,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 1_000_000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
return $player;
|
||||
}
|
||||
@@ -70,10 +70,12 @@ test('player me works with main site jwt when dev bypass is off', function () {
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$now = time();
|
||||
$jwt = JWT::encode([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'jwt-user-1',
|
||||
'exp' => time() + 3600,
|
||||
'iat' => $now,
|
||||
'exp' => $now + 300,
|
||||
], 'jwt-test-secret', 'HS256');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$jwt)
|
||||
@@ -88,10 +90,12 @@ test('jwt first successful login auto-registers player mapping', function () {
|
||||
|
||||
expect(Player::query()->count())->toBe(0);
|
||||
|
||||
$now = time();
|
||||
$jwt = JWT::encode([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'brand-new-sso-1',
|
||||
'exp' => time() + 3600,
|
||||
'iat' => $now,
|
||||
'exp' => $now + 300,
|
||||
], 'jwt-test-secret', 'HS256');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$jwt)
|
||||
|
||||
76
tests/Feature/RngSeedAuditTest.php
Normal file
76
tests/Feature/RngSeedAuditTest.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\DrawResultBatch;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Services\Draw\DrawRngSeedDerivation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('rng seed derivation is deterministic for fixed seed and draw', function (): void {
|
||||
$seedHex = str_repeat('ab', 32);
|
||||
$drawId = 42;
|
||||
|
||||
$a = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 0);
|
||||
$b = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 0);
|
||||
$c = DrawRngSeedDerivation::deriveNumber4d($seedHex, $drawId, 1);
|
||||
|
||||
expect($a)->toBe($b)
|
||||
->and(strlen($a))->toBe(4)
|
||||
->and($a)->not->toBe($c);
|
||||
});
|
||||
|
||||
test('rng seed encrypt decrypt roundtrip preserves hex', function (): void {
|
||||
$seedHex = DrawRngSeedDerivation::generateSeedHex();
|
||||
$encrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
|
||||
|
||||
expect(DrawRngSeedDerivation::decryptSeedHex($encrypted))->toBe($seedHex)
|
||||
->and(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe(hash('sha256', $seedHex));
|
||||
});
|
||||
|
||||
test('admin rng run stores encrypted seed and passes batch audit verification', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260525-rng-audit',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 901,
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'start_time' => now()->subMinutes(20),
|
||||
'close_time' => now()->subMinutes(5),
|
||||
'draw_time' => now()->subMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'rng_audit_admin',
|
||||
'name' => 'RNG Audit',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/rng")
|
||||
->assertOk();
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
|
||||
expect($batch->source_type)->toBe('rng')
|
||||
->and($batch->rng_seed_hash)->not->toBeEmpty()
|
||||
->and($batch->raw_seed_encrypted)->not->toBeEmpty()
|
||||
->and($batch->items()->count())->toBe(23);
|
||||
|
||||
$seedHex = DrawRngSeedDerivation::decryptSeedHex((string) $batch->raw_seed_encrypted);
|
||||
expect(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe($batch->rng_seed_hash)
|
||||
->and(DrawRngSeedDerivation::verifyBatchAudit($batch->fresh(['items']), $draw->fresh()))->toBeTrue();
|
||||
});
|
||||
@@ -14,6 +14,7 @@ use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\OddsItem;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -413,18 +414,238 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
|
||||
}
|
||||
});
|
||||
|
||||
test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void {
|
||||
$cases = [
|
||||
[
|
||||
'play' => 'pos_3abc',
|
||||
'number' => '234',
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '1234',
|
||||
'second' => '5234',
|
||||
'third' => '9234',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'expected_tier' => 'first',
|
||||
],
|
||||
[
|
||||
'play' => 'pos_3abc',
|
||||
'number' => '234',
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '1567',
|
||||
'second' => '5234',
|
||||
'third' => '8234',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'expected_tier' => 'second',
|
||||
],
|
||||
[
|
||||
'play' => 'pos_2abc',
|
||||
'number' => '99',
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '8899',
|
||||
'second' => '2299',
|
||||
'third' => '1199',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'expected_tier' => 'first',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($cases as $case) {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true),
|
||||
'lines' => [
|
||||
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000);
|
||||
|
||||
p145_publish_board($draw, $case['board']);
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
|
||||
|
||||
expect($item->status)->toBe('settled_win', $case['play'])
|
||||
->and((int) $item->win_amount)->toBe($expectedWin, $case['play'])
|
||||
->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']);
|
||||
}
|
||||
});
|
||||
|
||||
test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'module6-ibox-multi-tier',
|
||||
'lines' => [
|
||||
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$deduct = (int) $item->actual_deduct_amount;
|
||||
expect($deduct)->toBe(600)
|
||||
->and((int) $item->combination_count)->toBe(6);
|
||||
|
||||
$unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
|
||||
$unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000);
|
||||
$expectedWin = $unitWinFirst + $unitWinStarter;
|
||||
|
||||
p145_publish_board($draw, function (string $t, int $i): string {
|
||||
return match ($t) {
|
||||
'first' => '1212',
|
||||
'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i),
|
||||
default => p145_board_without_8888($t, $i),
|
||||
};
|
||||
});
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
|
||||
$matchLines = is_array($detail->match_detail_json)
|
||||
? ($detail->match_detail_json['lines'] ?? [])
|
||||
: [];
|
||||
|
||||
expect($item->status)->toBe('settled_win')
|
||||
->and((int) $item->win_amount)->toBe($expectedWin)
|
||||
->and($detail->matched_prize_tier)->toBe('first')
|
||||
->and(count($matchLines))->toBe(2);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin);
|
||||
});
|
||||
|
||||
test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
$rawAmount = 10_001;
|
||||
$comboCount = 24;
|
||||
$unitBet = intdiv($rawAmount, $comboCount);
|
||||
$expectedDeduct = $unitBet * $comboCount;
|
||||
$expectedRemainder = $rawAmount - $expectedDeduct;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'module6-mbox-remainder-win',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
|
||||
|
||||
expect((int) $item->combination_count)->toBe($comboCount)
|
||||
->and((int) $item->unit_bet_amount)->toBe($unitBet)
|
||||
->and((int) $item->total_bet_amount)->toBe($expectedDeduct)
|
||||
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
|
||||
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
|
||||
|
||||
$expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
|
||||
|
||||
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
expect($item->status)->toBe('settled_win')
|
||||
->and((int) $item->win_amount)->toBe($expectedWin);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct + $expectedWin);
|
||||
});
|
||||
|
||||
test('module 6 mbox remainder is not refunded on losing settlement', function (): void {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
$rawAmount = 10_001;
|
||||
$expectedDeduct = intdiv($rawAmount, 24) * 24;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'module6-mbox-remainder-lose',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct);
|
||||
|
||||
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
expect($item->status)->toBe('settled_lose')
|
||||
->and((int) $item->win_amount)->toBe(0);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct);
|
||||
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => '0.1000',
|
||||
'trigger_threshold' => 1,
|
||||
'payout_rate' => '1.0000',
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
]);
|
||||
JackpotPool::query()->updateOrCreate(
|
||||
['currency_code' => 'NPR'],
|
||||
[
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => '0.1000',
|
||||
'trigger_threshold' => 1,
|
||||
'payout_rate' => '1.0000',
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
],
|
||||
);
|
||||
|
||||
$player = p145_player();
|
||||
$drawNo = p145_next_draw_no();
|
||||
@@ -515,6 +736,117 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
|
||||
expect((int) $pool->locked_amount)->toBe(3000);
|
||||
});
|
||||
|
||||
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-odds-snapshot-1',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [])
|
||||
->firstWhere('prize_scope', 'first');
|
||||
expect($snapshotOdds)->not->toBeNull();
|
||||
|
||||
// 修改当前赔率配置:如果结算错误使用“实时配置”,这里会导致派奖金额变化。
|
||||
OddsItem::query()
|
||||
->where('play_code', 'big')
|
||||
->where('prize_scope', 'first')
|
||||
->where('currency_code', 'NPR')
|
||||
->update(['odds_value' => 10_000]);
|
||||
|
||||
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
$expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000));
|
||||
expect($item->status)->toBe('settled_win')
|
||||
->and((int) $item->win_amount)->toBe($expectedWinBySnapshot);
|
||||
});
|
||||
|
||||
test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
// 下注一单,确保产生风险池占用。
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-risk-release-1',
|
||||
'lines' => [
|
||||
['number' => '8888', 'play_code' => 'big', 'amount' => 120],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail();
|
||||
$cap = (int) $pool->total_cap_amount;
|
||||
expect((int) $pool->locked_amount)->toBeGreaterThan(0)
|
||||
->and((int) $pool->remaining_amount)->toBeLessThan($cap);
|
||||
|
||||
// 先走未中奖结算,验证释放。
|
||||
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$poolAfterLose = $pool->fresh();
|
||||
expect((int) $poolAfterLose->locked_amount)->toBe(0)
|
||||
->and((int) $poolAfterLose->remaining_amount)->toBe($cap);
|
||||
|
||||
// 再开一盘中奖结算,验证同样释放。
|
||||
$drawNo2 = p145_next_draw_no();
|
||||
$draw2 = p145_draw($drawNo2, random_int(1, 99_999));
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo2,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-risk-release-2',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail();
|
||||
$cap2 = (int) $pool2->total_cap_amount;
|
||||
expect((int) $pool2->locked_amount)->toBeGreaterThan(0);
|
||||
|
||||
p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
|
||||
$draw2->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue();
|
||||
p145_approve_and_payout($draw2);
|
||||
|
||||
$poolAfterWin = $pool2->fresh();
|
||||
expect((int) $poolAfterWin->locked_amount)->toBe(0)
|
||||
->and((int) $poolAfterWin->remaining_amount)->toBe($cap2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。
|
||||
* `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。
|
||||
|
||||
@@ -150,6 +150,84 @@ test('module 6 box family expands combinations and computes amount semantics', f
|
||||
->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17);
|
||||
});
|
||||
|
||||
test('module 6 mbox remainder splits amount evenly across preview and place', function (): void {
|
||||
$player = ticketPlayerWithWallet(500_000);
|
||||
ticketOpenDraw();
|
||||
|
||||
$cases = [
|
||||
[
|
||||
'number' => '1234',
|
||||
'amount' => 10_001,
|
||||
'combination_count' => 24,
|
||||
'unit_bet_amount' => 416,
|
||||
'total_bet_amount' => 9984,
|
||||
'rounding_refund_amount' => 17,
|
||||
],
|
||||
[
|
||||
'number' => '1122',
|
||||
'amount' => 601,
|
||||
'combination_count' => 6,
|
||||
'unit_bet_amount' => 100,
|
||||
'total_bet_amount' => 600,
|
||||
'rounding_refund_amount' => 1,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($cases as $index => $case) {
|
||||
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/preview', [
|
||||
'draw_id' => '20260511-001',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'trace-module6-mbox-remainder-'.$index,
|
||||
'lines' => [
|
||||
['number' => $case['number'], 'play_code' => 'mbox', 'amount' => $case['amount']],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$line = $resp->json('data.lines.0');
|
||||
expect($line['combination_count'])->toBe($case['combination_count'])
|
||||
->and($line['total_bet_amount'])->toBe($case['total_bet_amount'])
|
||||
->and($line['actual_deduct_amount'])->toBe($case['total_bet_amount'])
|
||||
->and($line['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe($case['rounding_refund_amount'])
|
||||
->and(intdiv($case['amount'], $case['combination_count']))->toBe($case['unit_bet_amount']);
|
||||
}
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-001',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'trace-module6-mbox-place-remainder',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.summary.total_bet_amount', 9984)
|
||||
->assertJsonPath('data.summary.total_actual_deduct', 9984);
|
||||
|
||||
$item = TicketItem::query()->where('play_code', 'mbox')->firstOrFail();
|
||||
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
|
||||
|
||||
expect((int) $item->unit_bet_amount)->toBe(416)
|
||||
->and((int) $item->total_bet_amount)->toBe(9984)
|
||||
->and((int) $item->actual_deduct_amount)->toBe(9984)
|
||||
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe(17);
|
||||
|
||||
$comboAmounts = TicketCombination::query()
|
||||
->where('ticket_item_id', $item->id)
|
||||
->pluck('bet_amount')
|
||||
->map(fn ($amount) => (int) $amount)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($comboAmounts)->toBe([416]);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(500_000 - 9984);
|
||||
});
|
||||
|
||||
test('module 6 roll expands each R position and charges per expanded combination', function (): void {
|
||||
$player = ticketPlayerWithWallet(500_000);
|
||||
ticketOpenDraw();
|
||||
@@ -537,6 +615,104 @@ test('ticket place rejects bet amount below configured minimum', function (): vo
|
||||
expect(TicketOrder::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('ticket preview reports high risk warning without deducting wallet or creating order', function (): void {
|
||||
$player = ticketPlayerWithWallet();
|
||||
$draw = ticketOpenDraw();
|
||||
|
||||
RiskPool::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'normalized_number' => '1234',
|
||||
'total_cap_amount' => 4000,
|
||||
'locked_amount' => 0,
|
||||
'remaining_amount' => 4000,
|
||||
'sold_out_status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/preview', [
|
||||
'draw_id' => '20260511-001',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'trace-preview-risk-warning',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 160],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.lines.0.risk_status', 'ok')
|
||||
->assertJsonPath('data.warnings.0.number_4d', '1234');
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail();
|
||||
|
||||
expect((int) $wallet->balance)->toBe(200_000)
|
||||
->and((int) $pool->locked_amount)->toBe(0)
|
||||
->and((int) $pool->remaining_amount)->toBe(4000)
|
||||
->and(TicketOrder::query()->count())->toBe(0)
|
||||
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('ticket preview validates digit size dimension and slot rules', function (): void {
|
||||
$player = ticketPlayerWithWallet();
|
||||
ticketOpenDraw();
|
||||
|
||||
$base = [
|
||||
'draw_id' => '20260511-001',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'trace-digit-validation',
|
||||
];
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/preview', $base + [
|
||||
'lines' => [
|
||||
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'digit_slot' => 3],
|
||||
],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/preview', $base + [
|
||||
'client_trace_id' => 'trace-digit-invalid-slot',
|
||||
'lines' => [
|
||||
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 1],
|
||||
],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value);
|
||||
|
||||
expect(TicketOrder::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('ticket place persists valid 2d digit size slot snapshot', function (): void {
|
||||
$player = ticketPlayerWithWallet(500_000);
|
||||
ticketOpenDraw();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-001',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'trace-digit-d2-slot',
|
||||
'lines' => [
|
||||
['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 3],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.summary.success_count', 1)
|
||||
->assertJsonPath('data.items.0.status', 'pending_draw');
|
||||
|
||||
$item = TicketItem::query()->where('play_code', 'digit_big')->firstOrFail();
|
||||
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
|
||||
|
||||
expect((int) $item->dimension)->toBe(2)
|
||||
->and((int) $item->digit_slot)->toBe(3)
|
||||
->and((int) $item->combination_count)->toBe(5000)
|
||||
->and($ruleSnapshot['dimension'] ?? null)->toBe('D2')
|
||||
->and($ruleSnapshot['digit_slot'] ?? null)->toBe(3)
|
||||
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0009')->exists())->toBeTrue()
|
||||
->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0004')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('ticket preview rejects invalid line amount per validation rules', function (): void {
|
||||
$player = ticketPlayerWithWallet();
|
||||
ticketOpenDraw();
|
||||
@@ -833,16 +1009,18 @@ test('ticket place reverses wallet and releases risk when post deduction confirm
|
||||
$player = ticketPlayerWithWallet(20_000);
|
||||
$draw = ticketOpenDraw();
|
||||
|
||||
JackpotPool::query()->create([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => 1,
|
||||
'trigger_threshold' => 0,
|
||||
'payout_rate' => 0,
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
JackpotPool::query()->updateOrCreate(
|
||||
['currency_code' => 'NPR'],
|
||||
[
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => 1,
|
||||
'trigger_threshold' => 0,
|
||||
'payout_rate' => 0,
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
],
|
||||
);
|
||||
DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END");
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
|
||||
Reference in New Issue
Block a user