'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('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]); 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]); 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('cooldown expiry tick moves draw to settling', function (): void { config([ 'lottery.draw.require_manual_review' => false, 'lottery.draw.cooldown_minutes' => 15, ]); 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::Settled->value); expect((int) $draw->settle_version)->toBe(1); expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'completed')->count())->toBe(1); 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.draw_no', '20260509-300') ->assertJsonPath('data.status', DrawStatus::Open->value) ->assertJsonPath('data.seconds_to_close', 60 * 60 - 30) ->assertJsonPath('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.draw_no', '20260509-310') ->assertJsonPath('data.status', DrawStatus::Closing->value) ->assertJsonPath('data.seconds_to_close', 0) ->assertJsonPath('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.draw_no', '20260509-311') ->assertJsonPath('data.status', DrawStatus::Closed->value) ->assertJsonPath('data.seconds_to_close', 0) ->assertJsonPath('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.status', DrawStatus::Cooldown->value) ->assertJsonPath('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', ]); $this->artisan('lottery:hall-countdown')->assertSuccessful(); Event::assertDispatched(DrawCountdownBroadcast::class); }); 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(); });