'UTC', 'lottery.draw.interval_minutes' => 60, 'lottery.draw.buffer_draws_ahead' => 3, 'lottery.draw.betting_window_seconds' => 270, 'lottery.draw.close_before_draw_seconds' => 30, 'lottery.draw.require_manual_review' => false, 'lottery.draw.cooldown_minutes' => 15, ]); }); test('draw planner fills buffer rows with ordered draw_no', function (): void { $fixed = Carbon::parse('2026-05-09 12:00:00', 'UTC')->utc(); /** @var DrawPlannerService $planner */ $planner = app(DrawPlannerService::class); $report = $planner->ensureBuffer($fixed); expect($report['created'])->toBeGreaterThan(0); expect(Draw::query()->count())->toBe($report['upcoming']); $drawNos = Draw::query()->orderBy('draw_time')->pluck('draw_no')->all(); $sorted = $drawNos; sort($sorted); expect($drawNos)->toEqual($sorted); }); test('admin can batch generate draw schedule buffer', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:00:00', 'UTC')); $admin = AdminUser::query()->create([ 'username' => 'draw_plan_admin', 'name' => 'Draw Plan Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/draws/generate-plan') ->assertOk() ->assertJsonPath('data.buffer_target', 3); expect(Draw::query()->count())->toBeGreaterThanOrEqual(3); Carbon::setTestNow(); }); test('admin can manually close open draw', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:10:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-120', 'business_date' => '2026-05-09', 'sequence_no' => 120, 'status' => DrawStatus::Open->value, 'start_time' => now()->copy()->subMinute(), 'close_time' => now()->copy()->addMinutes(10), 'draw_time' => now()->copy()->addMinutes(15), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'draw_close_admin', 'name' => 'Draw Close Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/manual-close") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Closing->value); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Closing->value); expect($draw->close_time?->timestamp)->toBe(now()->timestamp); Carbon::setTestNow(); }); test('admin can cancel draw before results exist', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 12:15:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-121', 'business_date' => '2026-05-09', 'sequence_no' => 121, 'status' => DrawStatus::Pending->value, 'start_time' => now()->copy()->addMinute(), 'close_time' => now()->copy()->addMinutes(10), 'draw_time' => now()->copy()->addMinutes(15), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'draw_cancel_admin', 'name' => 'Draw Cancel Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/cancel") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Cancelled->value); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Cancelled->value); Carbon::setTestNow(); }); test('admin can manually trigger rng for closed draw', function (): void { config(['lottery.draw.require_manual_review' => true]); Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-122', 'business_date' => '2026-05-09', 'sequence_no' => 122, 'status' => DrawStatus::Closed->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(5), 'draw_time' => now()->copy()->subMinute(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'draw_rng_admin', 'name' => 'Draw Rng Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/rng") ->assertOk() ->assertJsonPath('data.status', DrawStatus::Review->value) ->assertJsonPath('data.batch.source_type', 'rng') ->assertJsonPath('data.batch.items_count', 23); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Review->value); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); Carbon::setTestNow(); }); test('draw tick moves open draw to closing when close_time passed before draw_time', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC')); $drawTime = now()->copy()->addMinutes(30); $closeTime = now()->copy()->subMinute(); Draw::query()->create([ 'draw_no' => '20260509-099', 'business_date' => '2026-05-09', 'sequence_no' => 99, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(50), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-099')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Closing->value); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(0); Carbon::setTestNow(); }); test('draw tick rng publishes result when manual review disabled', function (): void { config(['lottery.draw.require_manual_review' => false]); LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:05:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-200', 'business_date' => '2026-05-09', 'sequence_no' => 200, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-200')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Cooldown->value); expect($draw->current_result_version)->toBe(1); expect($draw->cooling_end_time)->not->toBeNull(); $batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail(); expect($batch->status)->toBe(DrawResultBatchStatus::Published->value); expect($batch->items()->count())->toBe(23); Carbon::setTestNow(); }); test('draw tick rng awaits manual publish when review enabled', function (): void { config(['lottery.draw.require_manual_review' => true]); LotterySettings::put('draw.require_manual_review', true, 'draw', 'RNG 开奖后是否必须进入人工审核'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:06:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); $drawRow = Draw::query()->create([ 'draw_no' => '20260509-201', 'business_date' => '2026-05-09', 'sequence_no' => 201, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $drawRow->refresh(); expect($drawRow->status)->toBe(DrawStatus::Review->value); $batch = DrawResultBatch::query()->where('draw_id', $drawRow->id)->firstOrFail(); expect($batch->status)->toBe(DrawResultBatchStatus::PendingReview->value); $admin = AdminUser::query()->create([ 'username' => 'draw_auditor', 'name' => 'Auditor', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$batch->id}/publish") ->assertOk(); $drawRow->refresh(); $batch->refresh(); expect($drawRow->status)->toBe(DrawStatus::Cooldown->value); expect($batch->status)->toBe(DrawResultBatchStatus::Published->value); expect($drawRow->current_result_version)->toBe(1); expect($drawRow->cooling_end_time)->not->toBeNull(); Carbon::setTestNow(); }); test('admin can create manual result batch with 23 numbers for review', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:20:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-220', 'business_date' => '2026-05-09', 'sequence_no' => 220, 'status' => DrawStatus::Closed->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(2), 'draw_time' => now()->copy()->subMinute(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $admin = AdminUser::query()->create([ 'username' => 'manual_draw_admin', 'name' => 'Manual Draw Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $items = []; foreach (array_values(App\Services\Draw\DrawPrizeLayout::slots()) as $i => $slot) { $items[] = [ 'prize_type' => $slot['prize_type'], 'prize_index' => $slot['prize_index'], 'number_4d' => str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT), ]; } $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items]) ->assertOk() ->assertJsonPath('data.draw_no', '20260509-220') ->assertJsonPath('data.status', DrawStatus::Review->value) ->assertJsonPath('data.batch.status', DrawResultBatchStatus::PendingReview->value) ->assertJsonPath('data.batch.source_type', 'manual') ->assertJsonPath('data.batch.items_count', 23); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Review->value); expect($draw->result_source)->toBe('manual'); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('source_type', 'manual')->count())->toBe(1); expect(DrawResultItem::query()->where('draw_id', $draw->id)->count())->toBe(23); Carbon::setTestNow(); }); test('admin can reopen cooldown draw for a replacement result batch', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:30:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-230', 'business_date' => '2026-05-09', 'sequence_no' => 230, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(3), 'draw_time' => now()->copy()->subMinutes(2), 'cooling_end_time' => now()->copy()->addMinutes(10), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $batch = DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => hash('sha256', 'seed'), 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); foreach (App\Services\Draw\DrawPrizeLayout::slots() as $i => $slot) { $number = str_pad((string) ($i + 100), 4, '0', STR_PAD_LEFT); DrawResultItem::query()->create([ 'draw_id' => $draw->id, 'result_batch_id' => $batch->id, 'prize_type' => $slot['prize_type'], 'prize_index' => $slot['prize_index'], 'number_4d' => $number, 'suffix_3d' => substr($number, -3), 'suffix_2d' => substr($number, -2), 'head_digit' => (int) substr($number, 0, 1), 'tail_digit' => (int) substr($number, 3, 1), ]); } $admin = AdminUser::query()->create([ 'username' => 'reopen_draw_admin', 'name' => 'Reopen Draw Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/reopen", ['reason' => 'wrong result']) ->assertOk() ->assertJsonPath('data.draw_no', '20260509-230') ->assertJsonPath('data.status', DrawStatus::Closed->value) ->assertJsonPath('data.is_reopened', true) ->assertJsonPath('data.current_result_version', 1); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Closed->value); expect($draw->is_reopened)->toBeTrue(); expect($draw->cooling_end_time)->toBeNull(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/rng") ->assertOk() ->assertJsonPath('data.batch.result_version', 2) ->assertJsonPath('data.batch.items_count', 23); $draw->refresh(); expect($draw->current_result_version)->toBe(2); expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(2); Carbon::setTestNow(); }); test('non super admin cannot reopen cooldown draw', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 14:35:00', 'UTC')); $draw = Draw::query()->create([ 'draw_no' => '20260509-231', 'business_date' => '2026-05-09', 'sequence_no' => 231, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->copy()->subMinutes(20), 'close_time' => now()->copy()->subMinutes(3), 'draw_time' => now()->copy()->subMinutes(2), 'cooling_end_time' => now()->copy()->addMinutes(10), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $role = AdminRole::query()->create([ 'slug' => 'draw_manager_test', 'name' => 'Draw Manager Test', ]); $ids = DB::table('admin_menu_actions') ->whereIn('permission_code', App\Support\AdminPermissionBridge::menuActionCodesForLegacy('prd.draw_result.manage')) ->where('status', 1) ->pluck('id'); foreach ($ids as $mid) { DB::table('admin_role_menu_actions')->insert([ 'role_id' => $role->id, 'menu_action_id' => (int) $mid, ]); } $admin = AdminUser::query()->create([ 'username' => 'draw_manager_only', 'name' => 'Draw Manager Only', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $admin->roles()->sync([ (int) $role->id => [ 'site_id' => AdminUser::defaultAdminSiteId(), 'granted_at' => now(), ], ]); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/reopen") ->assertStatus(403); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Cooldown->value); expect($draw->is_reopened)->toBeFalse(); Carbon::setTestNow(); }); test('cooldown expiry tick moves draw to settling', function (): void { config([ 'lottery.draw.require_manual_review' => false, 'lottery.draw.cooldown_minutes' => 15, ]); LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核'); LotterySettings::put('draw.cooldown_minutes', 15, 'draw', '开奖结果发布后的冷静期分钟数'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-777', 'business_date' => '2026-05-09', 'sequence_no' => 777, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-777')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Cooldown->value); Carbon::setTestNow(Carbon::parse('2026-05-09 14:07:01', 'UTC')->addMinutes(16)); app(DrawTickService::class)->tick(now()->utc()); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Settling->value); expect((int) $draw->settle_version)->toBe(1); expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'pending_review')->count())->toBe(1); Carbon::setTestNow(); }); test('draw tick uses lottery settings to bypass manual review and cooldown', function (): void { config([ 'lottery.draw.require_manual_review' => true, 'lottery.draw.cooldown_minutes' => 15, ]); LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核'); LotterySettings::put('draw.cooldown_minutes', 0, 'draw', '开奖结果发布后的冷静期分钟数'); Carbon::setTestNow(Carbon::parse('2026-05-09 14:08:00', 'UTC')); $drawTime = now()->copy()->subMinute(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-778', 'business_date' => '2026-05-09', 'sequence_no' => 778, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(10), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); $draw = Draw::query()->where('draw_no', '20260509-778')->firstOrFail(); expect($draw->status)->toBe(DrawStatus::Settling->value); expect($draw->cooling_end_time)->toBeNull(); $batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail(); expect($batch->status)->toBe(DrawResultBatchStatus::Published->value); Carbon::setTestNow(); }); test('GET draw current returns open draw with seconds to close', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 15:00:00', 'UTC')); $drawTime = now()->copy()->addHour(); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-300', 'business_date' => '2026-05-09', 'sequence_no' => 300, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(5), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.draw_no', '20260509-300') ->assertJsonPath('data.data.status', DrawStatus::Open->value) ->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30) ->assertJsonPath('data.data.seconds_to_draw', 3600); Carbon::setTestNow(); }); test('GET draw current exposes closing when row is open in DB but close_time has passed', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:30:20', 'UTC')); $drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC'); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-310', 'business_date' => '2026-05-09', 'sequence_no' => 310, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(5), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.draw_no', '20260509-310') ->assertJsonPath('data.data.status', DrawStatus::Closing->value) ->assertJsonPath('data.data.seconds_to_close', 0) ->assertJsonPath('data.data.seconds_to_draw', 20); Carbon::setTestNow(); }); test('GET draw current exposes closed when row is open in DB but draw_time has passed', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:31:00', 'UTC')); $drawTime = Carbon::parse('2026-05-09 16:30:40', 'UTC'); $closeTime = $drawTime->copy()->subSeconds(30); Draw::query()->create([ 'draw_no' => '20260509-311', 'business_date' => '2026-05-09', 'sequence_no' => 311, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(5), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.draw_no', '20260509-311') ->assertJsonPath('data.data.status', DrawStatus::Closed->value) ->assertJsonPath('data.data.seconds_to_close', 0) ->assertJsonPath('data.data.seconds_to_draw', 0); Carbon::setTestNow(); }); test('GET draw current includes result_items when cooldown', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-09 16:10:00', 'UTC')); $drawRow = Draw::query()->create([ 'draw_no' => '20260509-400', 'business_date' => '2026-05-09', 'sequence_no' => 400, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->copy()->subHour(), 'close_time' => now()->copy()->subMinutes(30), 'draw_time' => now()->copy()->subMinutes(20), 'cooling_end_time' => now()->copy()->addMinutes(10), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $batch = DrawResultBatch::query()->create([ 'draw_id' => $drawRow->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => hash('sha256', 'fixture'), 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); DrawResultItem::query()->create([ 'draw_id' => $drawRow->id, 'result_batch_id' => $batch->id, 'prize_type' => 'first', 'prize_index' => 0, 'number_4d' => '1234', 'suffix_3d' => '234', 'suffix_2d' => '34', 'head_digit' => 1, 'tail_digit' => 4, ]); $this->getJson('/api/v1/draw/current') ->assertOk() ->assertJsonPath('data.data.status', DrawStatus::Cooldown->value) ->assertJsonPath('data.data.result_items.0.number_4d', '1234'); Carbon::setTestNow(); }); test('lottery draw-tick command runs successfully', function (): void { Carbon::setTestNow(Carbon::parse('2030-06-01 12:00:00', 'UTC')); $this->artisan('lottery:draw-tick')->assertSuccessful(); Carbon::setTestNow(); }); test('lottery hall-countdown dispatches draw.countdown when using reverb connection', function (): void { Event::fake([DrawCountdownBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); Draw::query()->create([ 'draw_no' => '20260509-001', 'business_date' => '2026-05-09', 'sequence_no' => 1, 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(5), 'close_time' => now()->addMinutes(20), 'draw_time' => now()->addMinutes(25), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $this->artisan('lottery:hall-countdown')->assertSuccessful(); Event::assertDispatched( DrawCountdownBroadcast::class, fn (DrawCountdownBroadcast $event): bool => is_array($event->data) && isset($event->data['draw_no']), ); }); test('hall snapshot switches to next bettable draw when cooldown ended', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC')); Draw::query()->create([ 'draw_no' => '20260510-001', 'business_date' => '2026-05-10', 'sequence_no' => 1, 'status' => DrawStatus::Cooldown->value, 'start_time' => now()->subHours(3), 'close_time' => now()->subHours(2), 'draw_time' => now()->subHours(2), 'cooling_end_time' => now()->subMinutes(5), 'result_source' => 'rng', 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); $next = Draw::query()->create([ 'draw_no' => '20260510-002', 'business_date' => '2026-05-10', 'sequence_no' => 2, 'status' => DrawStatus::Pending->value, 'start_time' => now()->subMinutes(1), 'close_time' => now()->addMinutes(20), 'draw_time' => now()->addMinutes(25), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc()); expect($target)->not->toBeNull() ->and($target->draw_no)->toBe('20260510-002'); $payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc()); expect($payload['status'] ?? null)->toBe('open'); Carbon::setTestNow(); }); test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void { Event::fake([DrawStatusChangeBroadcast::class]); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', ]); Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC')); $drawTime = now()->copy()->addMinutes(30); $closeTime = now()->copy()->subMinute(); Draw::query()->create([ 'draw_no' => '20260509-099', 'business_date' => '2026-05-09', 'sequence_no' => 99, 'status' => DrawStatus::Open->value, 'start_time' => $closeTime->copy()->subMinutes(50), 'close_time' => $closeTime, 'draw_time' => $drawTime, 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); app(DrawTickService::class)->tick(now()->utc()); Event::assertDispatched(DrawStatusChangeBroadcast::class); Carbon::setTestNow(); });