Files
lotteryLaravel/tests/Feature/OperationalConfigAcceptanceTest.php
kang 6b2ea39ea1
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
fix: 修复并发竞态与幂等性缺陷并优化子树查询
- 扩大玩家ID随机数位宽降低碰撞概率
- 授信额度变更加 lockForUpdate 防并发下调
- 结算冲正与入账利用唯一索引实现幂等闸门
- 失败登录计数加锁防丢失
- 代理子树查询改用子查询替代全表 pluck
- 修复返点默认值百分比单位转换
- 测试中全量 fake 广播事件防 CI 500
2026-06-18 09:38:24 +08:00

496 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 App\Services\Ticket\PlayCatalogResolver;
use App\Events\OddsUpdateBroadcast;
use App\Events\PlayToggleBroadcast;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
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 acceptanceOddsPutPayloadFromDetail(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,
'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,
);
});