diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index b482755..3bc31f6 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -5,6 +5,8 @@ use App\Lottery\DrawStatus; use App\Lottery\ErrorCode; use App\Models\Draw; use App\Models\OddsVersion; +use App\Models\PlayConfigItem; +use App\Models\PlayConfigVersion; use App\Models\Player; use App\Models\PlayerWallet; use App\Models\RiskPool; @@ -28,10 +30,12 @@ beforeEach(function (): void { function ticketPlayerWithWallet(int $balance = 200_000): Player { + $uniq = bin2hex(random_bytes(5)); + $player = Player::query()->create([ 'site_code' => 'test', - 'site_player_id' => 'ticket-player-1', - 'username' => 'tp1', + 'site_player_id' => 'ticket-player-'.$uniq, + 'username' => 'tp_'.$uniq, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, @@ -225,3 +229,159 @@ test('ticket place rejects sold out risk pool', function (): void { expect((int) $wallet->balance)->toBe(200_000); 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 3000(maxOdds 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); +});