Files
lotteryLaravel/tests/Feature/OperationalConfigAcceptanceTest.php

498 lines
20 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 可读出新值;玩法目录开关随版本草稿发布后生效。
* - 配置改动是否影响已下注订单 → ticket_items 行的 odds_snapshot_json 不因新赔率版本发布而被改写(注单链路尚未实现时,用数据不变量验证)。
* - 配置历史是否可追溯 → odds_versions 存在 archived + activeaudit_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\Events\OddsUpdateBroadcast;
use App\Events\PlayToggleBroadcast;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Event;
use App\Services\Ticket\PlayCatalogResolver;
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 acceptanceOddsPutPayloadFromDetail(array $items): array
{
return collect($items)->map(fn (array $r) => [
'provider_code' => $r['provider_code'] ?? 'GLOBAL',
'play_code' => $r['play_code'],
'prize_scope' => $r['prize_scope'],
'dimension' => $r['dimension'] ?? null,
'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,
'category' => $t->category,
'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode,
'display_name' => $t->display_name ?? $t->play_code,
'is_enabled' => true,
'min_bet_amount' => 777,
'max_bet_amount' => 400_000_000,
'display_order' => (int) $t->sort_order,
'supports_multi_number' => (bool) $t->supports_multi_number,
'reserved_rule_json' => $t->reserved_rule_json,
];
}
$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 = acceptanceOddsPutPayloadFromDetail($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' => acceptanceOddsPutPayloadFromDetail($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 = acceptanceOddsPutPayloadFromDetail($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 published play config controls master_enabled on public catalog without code deploy', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'acceptance master'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$rows = $this->getJson('/api/v1/admin/config/play-versions/'.$draftId, $auth)->assertOk()->json('data.items');
$payload = collect($rows)->map(fn (array $r) => [
'play_code' => $r['play_code'],
'category' => $r['category'],
'dimension' => $r['dimension'],
'bet_mode' => $r['bet_mode'],
'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'],
'display_order' => (int) $r['display_order'],
'supports_multi_number' => (bool) $r['supports_multi_number'],
'reserved_rule_json' => $r['reserved_rule_json'] ?? null,
'rule_text_zh' => $r['rule_text_zh'] ?? null,
'rule_text_en' => $r['rule_text_en'] ?? null,
'rule_text_ne' => $r['rule_text_ne'] ?? null,
'extra_config_json' => $r['extra_config_json'] ?? null,
])->all();
foreach ($payload as &$row) {
if ($row['play_code'] === 'big') {
$row['is_enabled'] = false;
}
}
unset($row);
$this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $payload], $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')['master_enabled'])->toBeFalse();
});
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('§10 default risk cap template applies to unconfigured numbers', function (): void {
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'default risk cap'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', [
'items' => [
[
'draw_id' => null,
'normalized_number' => '0000',
'cap_amount' => 12_345,
'cap_type' => 'default',
],
[
'draw_id' => null,
'normalized_number' => '1234',
'cap_amount' => 777,
'cap_type' => 'per_number',
],
],
], $auth)->assertOk();
$this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk();
$resolver = app(PlayCatalogResolver::class);
expect($resolver->resolveCapAmount(9999, '5678'))->toBe(12_345)
->and($resolver->resolveCapAmount(9999, '1234'))->toBe(777);
});
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,
'category' => $t->category,
'dimension' => $t->dimension,
'bet_mode' => $t->bet_mode,
'display_name' => $t->display_name ?? $t->play_code,
'is_enabled' => true,
'min_bet_amount' => 100,
'max_bet_amount' => 500_000_000,
'display_order' => (int) $t->sort_order,
'supports_multi_number' => (bool) $t->supports_multi_number,
'reserved_rule_json' => $t->reserved_rule_json,
];
}
$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();
});
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 {
// fake 流程中可能触发的全部 BroadcastPlayToggle / PlayCatalogUpdated / DrawStatusChange 等),
// 否则未在 fake 列表中的事件会真实连接 Reverb/PusherCI 环境抛 500。
Event::fake(allBroadcastEvents());
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'toggle broadcast'], $auth);
$create->assertOk();
$draftId = (int) $create->json('data.id');
$itemPayload = $create->json('data.items');
foreach ($itemPayload as &$row) {
if ($row['play_code'] === 'big') {
$row['is_enabled'] = false;
}
}
unset($row);
$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();
Event::assertDispatched(
PlayToggleBroadcast::class,
fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false,
);
});
test('§9 odds publish broadcasts odds update', function (): void {
// fake 流程中可能触发的全部 BroadcastOddsUpdate / PlayCatalogUpdated / DrawStatusChange 等),
// 否则未在 fake 列表中的事件会真实连接 Reverb/PusherCI 环境抛 500。
Event::fake(allBroadcastEvents());
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.driver' => 'reverb',
]);
$token = acceptanceMintAdminToken();
$auth = ['Authorization' => 'Bearer '.$token];
$create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'odds broadcast'], $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 = acceptanceOddsPutPayloadFromDetail($detail);
foreach ($payload as &$row) {
if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') {
$row['odds_value'] = 444_444;
}
}
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();
Event::assertDispatched(
OddsUpdateBroadcast::class,
fn (OddsUpdateBroadcast $event): bool => $event->versionId === $draftId,
);
});