feat: 添加新的错误码以支持配置版本管理,更新彩票配置以启用手动审核,增强 API 路由以支持玩法和赔率版本化管理
This commit is contained in:
63
tests/Feature/OddsStandardScopesSyncTest.php
Normal file
63
tests/Feature/OddsStandardScopesSyncTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use App\Models\Currency;
|
||||
use App\Models\OddsItem;
|
||||
use App\Models\OddsVersion;
|
||||
use App\Models\PlayType;
|
||||
use App\Support\OddsStandardScopes;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('syncMissingForVersion adds five scopes from default-only rows', function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
|
||||
$currency = Currency::query()->where('is_bettable', true)->where('is_enabled', true)->orderBy('code')->firstOrFail();
|
||||
|
||||
$version = OddsVersion::query()->create([
|
||||
'version_no' => 1,
|
||||
'status' => ConfigVersionStatus::Active->value,
|
||||
'effective_at' => now(),
|
||||
'updated_by' => null,
|
||||
'reason' => 'test',
|
||||
]);
|
||||
|
||||
foreach (PlayType::query()->orderBy('play_code')->get() as $pt) {
|
||||
OddsItem::query()->create([
|
||||
'version_id' => $version->id,
|
||||
'play_code' => $pt->play_code,
|
||||
'prize_scope' => 'default',
|
||||
'odds_value' => 19_500,
|
||||
'rebate_rate' => 0.01,
|
||||
'commission_rate' => 0,
|
||||
'currency_code' => $currency->code,
|
||||
'extra_config_json' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
OddsStandardScopes::syncMissingForVersion($version);
|
||||
|
||||
$playCount = PlayType::query()->count();
|
||||
expect(OddsItem::query()->where('version_id', $version->id)->count())->toBe(
|
||||
$playCount * (1 + count(OddsStandardScopes::SCOPE_KEYS)),
|
||||
);
|
||||
foreach (PlayType::query()->get() as $pt) {
|
||||
foreach (OddsStandardScopes::SCOPE_KEYS as $scope) {
|
||||
$row = OddsItem::query()
|
||||
->where('version_id', $version->id)
|
||||
->where('play_code', $pt->play_code)
|
||||
->where('currency_code', $currency->code)
|
||||
->where('prize_scope', $scope)
|
||||
->firstOrFail();
|
||||
|
||||
expect((int) $row->odds_value)->toBe(OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$scope]);
|
||||
/** @var string $rebate Eloquent `decimal:4` cast */
|
||||
$rebate = (string) $row->rebate_rate;
|
||||
expect($rebate)->toBe('0.0100');
|
||||
}
|
||||
}
|
||||
});
|
||||
321
tests/Feature/OperationalConfigAcceptanceTest.php
Normal file
321
tests/Feature/OperationalConfigAcceptanceTest.php
Normal file
@@ -0,0 +1,321 @@
|
||||
<?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\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,
|
||||
]);
|
||||
|
||||
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();
|
||||
});
|
||||
87
tests/Feature/OperationalConfigApiTest.php
Normal file
87
tests/Feature/OperationalConfigApiTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\PlayConfigVersion;
|
||||
use App\Models\PlayType;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
$this->seed(OperationalConfigV1Seeder::class);
|
||||
});
|
||||
|
||||
function mintConfigAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'config_admin',
|
||||
'name' => 'Config QA',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('play effective catalog is public and merged', function (): void {
|
||||
$resp = $this->getJson('/api/v1/play/effective?currency=NPR');
|
||||
$resp->assertOk()->assertJsonPath('data.currency_code', 'NPR');
|
||||
|
||||
$plays = $resp->json('data.plays');
|
||||
expect($plays)->toBeArray()->not->toBeEmpty();
|
||||
expect($plays[0])->toHaveKeys(['play_code', 'config', 'odds', 'master_enabled']);
|
||||
});
|
||||
|
||||
test('admin play config draft publish flow', function (): void {
|
||||
$token = mintConfigAdminToken();
|
||||
$active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail();
|
||||
|
||||
$create = $this->postJson('/api/v1/admin/config/play-versions', [
|
||||
'reason' => 'test draft',
|
||||
], ['Authorization' => 'Bearer '.$token]);
|
||||
$create->assertOk();
|
||||
$draftId = (int) $create->json('data.id');
|
||||
expect($draftId)->toBeGreaterThan(0);
|
||||
expect($create->json('data.status'))->toBe(ConfigVersionStatus::Draft->value);
|
||||
|
||||
$types = PlayType::query()->orderBy('play_code')->get();
|
||||
$itemPayload = [];
|
||||
foreach ($types as $t) {
|
||||
$itemPayload[] = [
|
||||
'play_code' => $t->play_code,
|
||||
'is_enabled' => true,
|
||||
'min_bet_amount' => 200,
|
||||
'max_bet_amount' => 400_000_000,
|
||||
'display_order' => (int) $t->sort_order,
|
||||
];
|
||||
}
|
||||
|
||||
$this->putJson(
|
||||
'/api/v1/admin/config/play-versions/'.$draftId.'/items',
|
||||
['items' => $itemPayload],
|
||||
['Authorization' => 'Bearer '.$token],
|
||||
)->assertOk()->assertJsonPath('data.items.0.min_bet_amount', 200);
|
||||
|
||||
$this->postJson(
|
||||
'/api/v1/admin/config/play-versions/'.$draftId.'/publish',
|
||||
[],
|
||||
['Authorization' => 'Bearer '.$token],
|
||||
)->assertOk()->assertJsonPath('data.status', ConfigVersionStatus::Active->value);
|
||||
|
||||
$active->refresh();
|
||||
expect($active->status)->toBe(ConfigVersionStatus::Archived->value);
|
||||
|
||||
expect(PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('admin play-types requires authentication', function (): void {
|
||||
$this->getJson('/api/v1/admin/play-types')->assertUnauthorized();
|
||||
});
|
||||
Reference in New Issue
Block a user