source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); $this->user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Web, 'config' => [], 'enabled' => true]); Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Telegram, 'config' => ['chat_id' => 1], 'enabled' => true]); Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]); $this->web = new FakeChannel(Presence::Absent); $this->telegram = new FakeChannel(Presence::Unknown); $this->webhook = new FakeChannel(Presence::Absent); $this->app->instance(ChannelRegistry::class, new FakeRegistry([ 'web' => $this->web, 'telegram' => $this->telegram, 'webhook' => $this->webhook, ])); } protected function tearDown(): void { CarbonImmutable::setTestNow(); parent::tearDown(); } private function event(string $due, string $fireOn, AfterDue $afterDue = AfterDue::Keep, string $time = '24:00:00'): Event { return Event::create([ 'source_id' => $this->source->id, 'user_id' => $this->user->id, 'source_ref' => 'r', 'topic' => uniqid(), 'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $fireOn, 'after_due' => $afterDue, 'payload' => ['title' => 'T'], 'state' => EventState::Scheduled, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), ]); } private function tickAt(string $utc): void { CarbonImmutable::setTestNow($utc); Cache::flush(); app(Tick::class)->run(); } public function test_moves_states_without_delivering_before_windows(): void { $e = $this->event('2026-09-30', '2026-08-31'); $this->tickAt('2026-08-31T03:00:00Z'); $this->assertSame(EventState::Preparing, $e->fresh()->state); $this->assertSame(0, Delivery::count(), 'тихие часы — доставок нет'); $this->tickAt('2026-10-01T00:00:00Z'); $this->assertSame(EventState::Overdue, $e->fresh()->state); } public function test_delivers_only_to_present_channels_and_once_per_window(): void { $e = $this->event('2026-09-30', '2026-08-31'); $this->webhook->presence = Presence::Present; $this->tickAt('2026-09-05T10:00:00Z'); $deliveries = Delivery::all(); $this->assertCount(1, $deliveries); $this->assertSame(ChannelType::Webhook, $deliveries->sole()->channel->type); $this->assertSame(DeliveryResult::Ok, $deliveries->sole()->result, 'sync-очередь: job уже отработал'); $this->assertSame('2026-09-05T09:00:00+00:00', $deliveries->sole()->window_start->toIso8601String()); $this->tickAt('2026-09-05T15:00:00Z'); $this->assertSame(1, Delivery::count(), 'второй раз в то же окно не шлём'); $this->tickAt('2026-09-06T10:00:00Z'); $this->assertSame(2, Delivery::count(), 'новое окно — новая доставка'); } public function test_falls_back_to_all_channels_near_window_end(): void { $this->event('2026-09-30', '2026-08-31'); $this->tickAt('2026-09-05T21:00:00Z'); $this->assertSame(0, Delivery::count(), 'никто не present, до fallback ещё далеко'); $this->tickAt('2026-09-05T21:31:00Z'); $this->assertSame(3, Delivery::count(), 'fallback во все три канала'); $this->assertSame([1, 1, 1], [count($this->web->delivered), count($this->telegram->delivered), count($this->webhook->delivered)]); } public function test_today_fallback_limited_to_listed_hours(): void { $this->event('2026-09-30', '2026-09-30'); $this->tickAt('2026-09-30T11:55:00Z'); $this->assertSame(0, Delivery::count(), '11:00 не fallback-час'); $this->tickAt('2026-09-30T14:55:00Z'); $this->assertSame(3, Delivery::count(), '14:00 — fallback-час'); } public function test_today_hourly_when_present(): void { $this->event('2026-09-30', '2026-09-30'); $this->web->presence = Presence::Present; $this->tickAt('2026-09-30T11:05:00Z'); $this->tickAt('2026-09-30T11:45:00Z'); $this->tickAt('2026-09-30T12:05:00Z'); $this->assertSame(2, Delivery::count()); $this->assertSame(0, count($this->telegram->delivered), 'в telegram не шли: web present'); } public function test_quiet_until_blocks_delivery(): void { $e = $this->event('2026-09-30', '2026-08-31'); $e->update(['quiet_until' => CarbonImmutable::parse('2026-09-15T00:00:00Z')]); $this->webhook->presence = Presence::Present; $this->tickAt('2026-09-10T10:00:00Z'); $this->assertSame(0, Delivery::count()); $this->tickAt('2026-09-15T10:00:00Z'); $this->assertSame(1, Delivery::count()); } public function test_disabled_channel_is_skipped_and_user_without_channels_is_fine(): void { Channel::where('type', ChannelType::Webhook->value)->update(['enabled' => false]); $this->webhook->presence = Presence::Present; $this->event('2026-09-30', '2026-08-31'); $this->tickAt('2026-09-05T21:31:00Z'); $this->assertSame(2, Delivery::count(), 'fallback только в включённые'); Channel::query()->update(['enabled' => false]); $this->tickAt('2026-09-06T21:31:00Z'); $this->assertSame(2, Delivery::count()); } public function test_user_timezone_shifts_windows(): void { $this->user->update(['tz' => 'Asia/Shanghai']); // UTC+8 $this->event('2026-09-30', '2026-08-31'); $this->webhook->presence = Presence::Present; $this->tickAt('2026-09-05T00:30:00Z'); // 08:30 в Шанхае — тихие часы $this->assertSame(0, Delivery::count()); $this->tickAt('2026-09-05T01:30:00Z'); // 09:30 в Шанхае $this->assertSame(1, Delivery::count()); } public function test_presence_is_cached_for_a_minute(): void { $this->event('2026-09-30', '2026-08-31'); $this->webhook->presence = Presence::Present; CarbonImmutable::setTestNow('2026-09-05T10:00:00Z'); app(Tick::class)->run(); $this->webhook->presence = Presence::Absent; $this->assertSame(Presence::Present, Cache::get('presence:'.Channel::where('type', 'webhook')->sole()->id)); } }