323 lines
13 KiB
PHP
323 lines
13 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 验收自动化(对应测试任务 §5 / 完成标准 §12.6):
|
||
*
|
||
* - 配置改动是否生效 → 赔率 / 玩法限额发布后 GET /api/v1/play/effective 可读出新值;PATCH play-types 可改目录开关。
|
||
* - 配置改动是否影响已下注订单 → ticket_items 行的 odds_snapshot_json 不因新赔率版本发布而被改写(注单链路尚未实现时,用数据不变量验证)。
|
||
* - 配置历史是否可追溯 → odds_versions 存在 archived + active;audit_logs 记录 publish。
|
||
*
|
||
* 运行:php artisan test tests/Feature/OperationalConfigAcceptanceTest.php
|
||
*/
|
||
|
||
use App\Models\Draw;
|
||
use App\Models\Player;
|
||
use App\Models\AuditLog;
|
||
use App\Models\PlayType;
|
||
use App\Models\AdminUser;
|
||
use App\Lottery\DrawStatus;
|
||
use App\Models\OddsVersion;
|
||
use App\Models\RiskCapVersion;
|
||
use Illuminate\Support\Facades\DB;
|
||
use App\Lottery\ConfigVersionStatus;
|
||
use Database\Seeders\CurrencySeeder;
|
||
use Database\Seeders\PlayTypeSeeder;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Database\Seeders\OperationalConfigV1Seeder;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
beforeEach(function (): void {
|
||
$this->seed(CurrencySeeder::class);
|
||
$this->seed(PlayTypeSeeder::class);
|
||
$this->seed(OperationalConfigV1Seeder::class);
|
||
});
|
||
|
||
function acceptanceMintAdminToken(): string
|
||
{
|
||
$admin = AdminUser::query()->create([
|
||
'username' => 'acceptance_admin',
|
||
'name' => 'Acceptance QA',
|
||
'email' => null,
|
||
'password' => Hash::make('secret-strong'),
|
||
'status' => 0,
|
||
]);
|
||
grantSuperAdminRole($admin);
|
||
|
||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||
}
|
||
|
||
function oddsPutPayloadFromDetail(array $items): array
|
||
{
|
||
return collect($items)->map(fn (array $r) => [
|
||
'play_code' => $r['play_code'],
|
||
'prize_scope' => $r['prize_scope'],
|
||
'odds_value' => (int) $r['odds_value'],
|
||
'rebate_rate' => (float) $r['rebate_rate'],
|
||
'commission_rate' => (float) $r['commission_rate'],
|
||
'currency_code' => $r['currency_code'],
|
||
'extra_config_json' => $r['extra_config_json'] ?? null,
|
||
])->all();
|
||
}
|
||
|
||
test('§12.6 published play limits are visible on public effective catalog without code deploy', function (): void {
|
||
$token = acceptanceMintAdminToken();
|
||
$auth = ['Authorization' => 'Bearer '.$token];
|
||
|
||
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance'], $auth);
|
||
$create->assertOk();
|
||
$draftId = (int) $create->json('data.id');
|
||
|
||
$itemPayload = [];
|
||
foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
|
||
$itemPayload[] = [
|
||
'play_code' => $t->play_code,
|
||
'is_enabled' => true,
|
||
'min_bet_amount' => 777,
|
||
'max_bet_amount' => 400_000_000,
|
||
'display_order' => (int) $t->sort_order,
|
||
];
|
||
}
|
||
|
||
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
|
||
$this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||
|
||
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||
expect($plays->firstWhere('play_code', 'big')['config']['min_bet_amount'])->toBe(777);
|
||
});
|
||
|
||
test('§12.6 published odds are visible on public effective catalog without code deploy', function (): void {
|
||
$token = acceptanceMintAdminToken();
|
||
$auth = ['Authorization' => 'Bearer '.$token];
|
||
|
||
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'acceptance odds'], $auth);
|
||
$create->assertOk();
|
||
$draftId = (int) $create->json('data.id');
|
||
|
||
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||
$payload = oddsPutPayloadFromDetail($detail);
|
||
foreach ($payload as &$row) {
|
||
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
|
||
$row['odds_value'] = 333_333;
|
||
}
|
||
}
|
||
unset($row);
|
||
|
||
$this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
|
||
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||
|
||
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||
expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(333_333);
|
||
});
|
||
|
||
test('§5 odds publish archives prior version lists history and writes audit log', function (): void {
|
||
$token = acceptanceMintAdminToken();
|
||
$auth = ['Authorization' => 'Bearer '.$token];
|
||
|
||
$beforeArchived = OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();
|
||
|
||
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'audit trail'], $auth);
|
||
$create->assertOk();
|
||
$draftId = (int) $create->json('data.id');
|
||
|
||
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||
$this->putJson(
|
||
'/api/v1/admin/config/odds-versions/'.$draftId.'/items',
|
||
['items' => oddsPutPayloadFromDetail($detail)],
|
||
$auth,
|
||
)->assertOk();
|
||
|
||
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||
|
||
expect(OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1);
|
||
expect(OddsVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);
|
||
|
||
$list = $this->getJson('/api/v1/admin/config/odds-versions?per_page=50', $auth)->assertOk()->json('data.items');
|
||
expect(count($list))->toBeGreaterThanOrEqual(2);
|
||
|
||
expect(
|
||
AuditLog::query()
|
||
->where('module_code', 'odds')
|
||
->where('action_code', 'publish')
|
||
->exists(),
|
||
)->toBeTrue();
|
||
});
|
||
|
||
test('§5 existing ticket_items odds snapshot row is not mutated when new odds version publishes', function (): void {
|
||
$token = acceptanceMintAdminToken();
|
||
$auth = ['Authorization' => 'Bearer '.$token];
|
||
|
||
$snapshot = ['frozen_odds_first' => 250_000, 'note' => 'acceptance synthetic row'];
|
||
|
||
$player = Player::query()->create([
|
||
'site_code' => 'test',
|
||
'site_player_id' => 'p1',
|
||
'username' => 'u1',
|
||
'nickname' => null,
|
||
'default_currency' => 'NPR',
|
||
'status' => 0,
|
||
]);
|
||
|
||
$draw = Draw::query()->create([
|
||
'draw_no' => 'ACC-001',
|
||
'business_date' => now()->toDateString(),
|
||
'sequence_no' => 1,
|
||
'status' => DrawStatus::Open->value,
|
||
'start_time' => null,
|
||
'close_time' => null,
|
||
'draw_time' => null,
|
||
'cooling_end_time' => null,
|
||
'result_source' => null,
|
||
'current_result_version' => 0,
|
||
'settle_version' => 0,
|
||
'is_reopened' => false,
|
||
]);
|
||
|
||
$now = now();
|
||
$orderId = DB::table('ticket_orders')->insertGetId([
|
||
'order_no' => 'ORD-ACC-001',
|
||
'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' => 0,
|
||
'status' => 'confirmed',
|
||
'submit_source' => 'h5',
|
||
'client_trace_id' => null,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
]);
|
||
|
||
DB::table('ticket_items')->insert([
|
||
'ticket_no' => 'TICK-ACC-001',
|
||
'order_id' => $orderId,
|
||
'player_id' => $player->id,
|
||
'draw_id' => $draw->id,
|
||
'original_number' => null,
|
||
'normalized_number' => '1234',
|
||
'play_code' => 'big',
|
||
'dimension' => 2,
|
||
'digit_slot' => null,
|
||
'bet_mode' => null,
|
||
'unit_bet_amount' => 100,
|
||
'total_bet_amount' => 100,
|
||
'rebate_rate_snapshot' => 0,
|
||
'commission_rate_snapshot' => 0,
|
||
'actual_deduct_amount' => 100,
|
||
'odds_snapshot_json' => json_encode($snapshot),
|
||
'rule_snapshot_json' => null,
|
||
'combination_count' => 1,
|
||
'estimated_max_payout' => 0,
|
||
'risk_locked_amount' => 0,
|
||
'status' => 'confirmed',
|
||
'fail_reason_code' => null,
|
||
'fail_reason_text' => null,
|
||
'win_amount' => 0,
|
||
'jackpot_win_amount' => 0,
|
||
'settled_at' => null,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
]);
|
||
|
||
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'after ticket'], $auth);
|
||
$create->assertOk();
|
||
$draftId = (int) $create->json('data.id');
|
||
|
||
$detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||
$payload = oddsPutPayloadFromDetail($detail);
|
||
foreach ($payload as &$row) {
|
||
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
|
||
$row['odds_value'] = 9_999_999;
|
||
}
|
||
}
|
||
unset($row);
|
||
|
||
$this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
|
||
$this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||
|
||
$stored = DB::table('ticket_items')->where('ticket_no', 'TICK-ACC-001')->value('odds_snapshot_json');
|
||
expect(json_decode((string) $stored, true))->toBe($snapshot);
|
||
|
||
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||
expect($plays->firstWhere('play_code', 'big')['odds']['odds_value'])->toBe(9_999_999);
|
||
});
|
||
|
||
test('§12.6 PATCH play-types controls master_enabled on public catalog without code deploy', function (): void {
|
||
$token = acceptanceMintAdminToken();
|
||
$auth = ['Authorization' => 'Bearer '.$token];
|
||
|
||
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth)->assertOk();
|
||
|
||
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
|
||
expect($plays->firstWhere('play_code', 'big')['master_enabled'])->toBeFalse();
|
||
|
||
$this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => true], $auth)->assertOk();
|
||
});
|
||
|
||
test('§5 risk cap publish is audited and version history exists', function (): void {
|
||
$token = acceptanceMintAdminToken();
|
||
$auth = ['Authorization' => 'Bearer '.$token];
|
||
|
||
$beforeArchived = RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count();
|
||
|
||
$create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'acceptance risk'], $auth);
|
||
$create->assertOk();
|
||
$draftId = (int) $create->json('data.id');
|
||
|
||
$rows = $this->getJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, $auth)->assertOk()->json('data.items');
|
||
$payload = collect($rows)->map(fn (array $r) => [
|
||
'draw_id' => $r['draw_id'],
|
||
'normalized_number' => $r['normalized_number'],
|
||
'cap_amount' => (int) $r['cap_amount'],
|
||
'cap_type' => $r['cap_type'],
|
||
])->all();
|
||
|
||
$this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk();
|
||
$this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||
|
||
expect(RiskCapVersion::query()->where('status', ConfigVersionStatus::Archived->value)->count())->toBe($beforeArchived + 1);
|
||
|
||
expect(
|
||
AuditLog::query()
|
||
->where('module_code', 'risk_cap')
|
||
->where('action_code', 'publish')
|
||
->exists(),
|
||
)->toBeTrue();
|
||
|
||
$list = $this->getJson('/api/v1/admin/config/risk-cap-versions?per_page=50', $auth)->assertOk()->json('data.items');
|
||
expect(count($list))->toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
test('§5 play_config publish is audited', function (): void {
|
||
$token = acceptanceMintAdminToken();
|
||
$auth = ['Authorization' => 'Bearer '.$token];
|
||
|
||
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'audit play'], $auth);
|
||
$create->assertOk();
|
||
$draftId = (int) $create->json('data.id');
|
||
|
||
$itemPayload = [];
|
||
foreach (PlayType::query()->orderBy('play_code')->get() as $t) {
|
||
$itemPayload[] = [
|
||
'play_code' => $t->play_code,
|
||
'is_enabled' => true,
|
||
'min_bet_amount' => 100,
|
||
'max_bet_amount' => 500_000_000,
|
||
'display_order' => (int) $t->sort_order,
|
||
];
|
||
}
|
||
|
||
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk();
|
||
$this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk();
|
||
|
||
expect(
|
||
AuditLog::query()
|
||
->where('module_code', 'play_config')
|
||
->where('action_code', 'publish')
|
||
->exists(),
|
||
)->toBeTrue();
|
||
});
|