feat: 增强投注功能测试,添加多个场景以验证投注请求的有效性和错误处理,包括禁用玩法、最低投注金额和并发下注的处理

This commit is contained in:
2026-05-11 14:00:47 +08:00
parent 058f596f34
commit 6a55fa9592

View File

@@ -5,6 +5,8 @@ use App\Lottery\DrawStatus;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Models\Draw; use App\Models\Draw;
use App\Models\OddsVersion; use App\Models\OddsVersion;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Models\Player; use App\Models\Player;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Models\RiskPool; use App\Models\RiskPool;
@@ -28,10 +30,12 @@ beforeEach(function (): void {
function ticketPlayerWithWallet(int $balance = 200_000): Player function ticketPlayerWithWallet(int $balance = 200_000): Player
{ {
$uniq = bin2hex(random_bytes(5));
$player = Player::query()->create([ $player = Player::query()->create([
'site_code' => 'test', 'site_code' => 'test',
'site_player_id' => 'ticket-player-1', 'site_player_id' => 'ticket-player-'.$uniq,
'username' => 'tp1', 'username' => 'tp_'.$uniq,
'nickname' => null, 'nickname' => null,
'default_currency' => 'NPR', 'default_currency' => 'NPR',
'status' => 0, 'status' => 0,
@@ -225,3 +229,159 @@ test('ticket place rejects sold out risk pool', function (): void {
expect((int) $wallet->balance)->toBe(200_000); expect((int) $wallet->balance)->toBe(200_000);
expect(TicketOrder::query()->count())->toBe(0); expect(TicketOrder::query()->count())->toBe(0);
}); });
test('ticket place rejects disabled play from active catalog', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versionId = PlayConfigVersion::query()
->where('status', ConfigVersionStatus::Active->value)
->value('id');
expect($versionId)->not->toBeNull();
PlayConfigItem::query()
->where('version_id', $versionId)
->where('play_code', 'big')
->update(['is_enabled' => false]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->withHeader('X-Locale', 'zh')
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-disabled-play',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
->assertJsonPath('msg', __('wallet.2002', [], 'zh'));
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects bet amount below configured minimum', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-min-bet',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 50],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::WalletAmountExceedsLimit->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket preview rejects invalid line amount per validation rules', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-invalid-amt',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 0],
],
])
->assertStatus(422);
});
/**
* §13.5 风险占用回滚同一订单两行共享号码时preview 各自通过;首条 acquire 后剩余不足则第二条失败,整单事务回滚。
* big + amount 120 estimated_payout 3000maxOdds 250000 / 10000 = 25)。
*/
test('ticket place rolls back order wallet and risk locks when mid-order acquire fails', function (): void {
$player = ticketPlayerWithWallet(500_000);
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 0,
'remaining_amount' => 5000,
'sold_out_status' => 0,
'version' => 0,
]);
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-rollback',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
];
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
expect(TicketOrder::query()->count())->toBe(0);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000);
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', '1234')
->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(5000);
expect((int) $pool->locked_amount)->toBe(0);
});
/** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */
test('ticket place sold out for second player after first consumes shared pool', function (): void {
$draw = ticketOpenDraw();
$playerA = ticketPlayerWithWallet();
$playerB = ticketPlayerWithWallet();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 0,
'remaining_amount' => 5000,
'sold_out_status' => 0,
'version' => 0,
]);
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-race-a',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
],
];
$this->withHeader('Authorization', 'Bearer dev:'.$playerA->id)
->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-a']))
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
$this->withHeader('Authorization', 'Bearer dev:'.$playerB->id)
->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-b']))
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
expect(TicketOrder::query()->count())->toBe(1);
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', '1234')
->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(2000);
});