Files
lotteryLaravel/tests/Feature/TicketBettingApiTest.php

589 lines
22 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
use App\Models\Draw;
use App\Models\Player;
use App\Models\RiskPool;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\OddsVersion;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\PlayConfigItem;
use App\Models\PlayConfigVersion;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\PlayTypeSeeder;
use Database\Seeders\LotterySettingsSeeder;
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);
$this->seed(LotterySettingsSeeder::class);
});
function ticketPlayerWithWallet(int $balance = 200_000): Player
{
$uniq = bin2hex(random_bytes(5));
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ticket-player-'.$uniq,
'username' => 'tp_'.$uniq,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => $balance,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}
function ticketOpenDraw(string $drawNo = '20260511-001'): Draw
{
return Draw::query()->create([
'draw_no' => $drawNo,
'business_date' => '2026-05-11',
'sequence_no' => 1,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinutes(2),
'close_time' => now()->addMinutes(5),
'draw_time' => now()->addMinutes(6),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
}
function ticketPreviewPayload(string $drawNo = '20260511-001'): array
{
return [
'draw_id' => $drawNo,
'currency_code' => 'NPR',
'client_trace_id' => 'trace-001',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
[
'number' => '1234',
'play_code' => 'ibox',
'amount' => 100,
],
],
];
}
test('ticket preview returns computed summary for open draw', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400)
->assertJsonPath('data.config_versions.play_config_version_no', 1)
->assertJsonPath('data.config_versions.odds_version_no', 1)
->assertJsonPath('data.config_versions.risk_cap_version_no', 1)
->assertJsonCount(2, 'data.lines');
});
test('module 6 box family expands combinations and computes amount semantics', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$payload = [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-box',
'lines' => [
['number' => '1234', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1123', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1122', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1112', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1111', 'play_code' => 'box', 'amount' => 10_000],
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001],
],
];
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', $payload)
->assertOk();
$lines = collect($resp->json('data.lines'))->keyBy('client_line_no');
expect($lines[1]['combination_count'])->toBe(24)
->and($lines[2]['combination_count'])->toBe(12)
->and($lines[3]['combination_count'])->toBe(6)
->and($lines[4]['combination_count'])->toBe(4)
->and($lines[5]['combination_count'])->toBe(1)
->and($lines[6]['combination_count'])->toBe(6)
->and($lines[6]['total_bet_amount'])->toBe(600)
->and($lines[7]['combination_count'])->toBe(24)
->and($lines[7]['total_bet_amount'])->toBe(9_984)
->and($lines[7]['actual_deduct_amount'])->toBe(9_984)
->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17);
});
test('module 6 roll expands each R position and charges per expanded combination', function (): void {
$player = ticketPlayerWithWallet(500_000);
ticketOpenDraw();
$resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-roll',
'lines' => [
['number' => 'R234', 'play_code' => 'roll', 'amount' => 100],
['number' => 'RR34', 'play_code' => 'roll', 'amount' => 100],
],
])
->assertOk();
$lines = collect($resp->json('data.lines'))->keyBy('client_line_no');
expect($lines[1]['combination_count'])->toBe(10)
->and($lines[1]['total_bet_amount'])->toBe(1_000)
->and($lines[2]['combination_count'])->toBe(100)
->and($lines[2]['total_bet_amount'])->toBe(10_000);
});
test('module 6 reserved and phase two plays are not available for betting or public entry', 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-module6-half-box',
'lines' => [
['number' => '1234', 'play_code' => 'half_box', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-module6-5d',
'lines' => [
['number' => '12345', 'play_code' => '5d', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value);
$plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays'));
expect($plays->firstWhere('play_code', 'half_box')['config']['is_enabled'])->toBeFalse()
->and($plays->contains('play_code', '5d'))->toBeFalse()
->and($plays->contains('play_code', '6d'))->toBeFalse();
});
test('ticket place deducts wallet and persists order items combinations and logs', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.draw.draw_id', '20260511-001')
->assertJsonPath('data.summary.total_bet_amount', 12_400)
->assertJsonPath('data.summary.total_actual_deduct', 12_400);
expect(TicketOrder::query()->count())->toBe(1);
expect(TicketItem::query()->count())->toBe(2);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
expect(RiskPool::query()->count())->toBeGreaterThan(0);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
});
test('ticket place rejects closed draw', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$draw->update(['status' => DrawStatus::Closed->value]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
});
test('ticket preview and place reject open draw after server close time', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
$draw->update(['close_time' => now()->subSecond()]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::DrawClosed->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects insufficient balance', function (): void {
$player = ticketPlayerWithWallet(1_000);
ticketOpenDraw();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', ticketPreviewPayload())
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);
expect(TicketOrder::query()->count())->toBe(0);
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
});
test('ticket place succeeds when expected_config_versions matches preview', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$versions = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $versions]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value);
expect(TicketOrder::query()->count())->toBe(1);
});
test('ticket place rejects stale expected_config_versions', function (): void {
$player = ticketPlayerWithWallet();
ticketOpenDraw();
$preview = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/preview', ticketPreviewPayload())
->assertOk()
->json('data.config_versions');
OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->update(['version_no' => 99]);
$payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $preview]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', $payload)
->assertStatus(400)
->assertJsonPath('code', ErrorCode::BetConfigStale->value);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place rejects sold out risk pool', function (): void {
$player = ticketPlayerWithWallet();
$draw = ticketOpenDraw();
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 100,
'locked_amount' => 100,
'remaining_amount' => 0,
'sold_out_status' => 1,
'version' => 1,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-002',
'lines' => [
[
'number' => '1234',
'play_code' => 'big',
'amount' => 10_000,
],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(200_000);
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket place can return mixed success and failed risk results', 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,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '5678',
'total_cap_amount' => 100,
'locked_amount' => 100,
'remaining_amount' => 0,
'sold_out_status' => 1,
'version' => 1,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/ticket/place', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-mixed-risk',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
['number' => '5678', 'play_code' => 'big', 'amount' => 120],
],
])
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.summary.failure_count', 1)
->assertJsonPath('data.items.0.status', 'success')
->assertJsonPath('data.items.1.status', 'failed')
->assertJsonPath('data.items.1.fail_reason_code', (string) ErrorCode::RiskPoolSoldOut->value);
$order = TicketOrder::query()->firstOrFail();
expect($order->status)->toBe('partial_failed')
->and((int) $order->total_actual_deduct)->toBe(120)
->and(TicketItem::query()->where('status', 'success')->count())->toBe(1)
->and(TicketItem::query()->where('status', 'failed')->count())->toBe(1);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000 - 120);
});
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'))
->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理')
->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
->assertJsonPath('data.cleanup_lines.0.play_code', 'big');
expect(TicketOrder::query()->count())->toBe(0);
});
test('ticket preview returns cleanup hint when draft contains closed play', 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/preview', [
'draw_id' => '20260511-001',
'currency_code' => 'NPR',
'client_trace_id' => 'trace-preview-closed-play',
'lines' => [
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
],
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::PlayModeClosed->value)
->assertJsonPath('msg', __('wallet.2002', [], 'zh'))
->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理')
->assertJsonPath('data.cleanup_lines.0.client_line_no', 1)
->assertJsonPath('data.cleanup_lines.0.play_code', 'big');
});
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);
});
/**
* §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败。
* big + amount 120 → estimated_payout 3000maxOdds 250000 / 10000 = 25
*/
test('ticket place records partial failed 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)
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.summary.success_count', 1)
->assertJsonPath('data.summary.failure_count', 1);
expect(TicketOrder::query()->count())->toBe(1);
expect(TicketOrder::query()->firstOrFail()->status)->toBe('partial_failed');
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
expect((int) $wallet->balance)->toBe(500_000 - 120);
$pool = RiskPool::query()
->where('draw_id', $draw->id)
->where('normalized_number', '1234')
->firstOrFail();
expect((int) $pool->remaining_amount)->toBe(2000);
expect((int) $pool->locked_amount)->toBe(3000);
});
/** §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);
});