Files
lotteryLaravel/tests/Feature/OperationalConfigAcceptanceTest.php

323 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 验收自动化(对应测试任务 §5 / 完成标准 §12.6
*
* - 配置改动是否生效 → 赔率 / 玩法限额发布后 GET /api/v1/play/effective 可读出新值PATCH play-types 可改目录开关。
* - 配置改动是否影响已下注订单 → ticket_items 行的 odds_snapshot_json 不因新赔率版本发布而被改写(注单链路尚未实现时,用数据不变量验证)。
* - 配置历史是否可追溯 → odds_versions 存在 archived + activeaudit_logs 记录 publish。
*
* 运行php artisan test tests/Feature/OperationalConfigAcceptanceTest.php
*/
use App\Lottery\ConfigVersionStatus;
use App\Lottery\DrawStatus;
use App\Models\AdminUser;
use App\Models\AuditLog;
use App\Models\Draw;
use App\Models\OddsVersion;
use App\Models\Player;
use App\Models\PlayType;
use App\Models\RiskCapVersion;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\OperationalConfigV1Seeder;
use Database\Seeders\PlayTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
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();
});