$source->id, 'user_id' => $user->id, 'source_ref' => 'person:42', 'topic' => $topic, 'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', 'after_due' => AfterDue::Expire, 'payload' => ['title' => 'ДР'], 'state' => EventState::Scheduled, 'quiet_until' => CarbonImmutable::parse('2000-01-01T00:00:00Z'), ]); } public function test_event_identity_is_unique_per_source_ref_topic(): void { $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); $user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); $this->makeEvent($source, $user); $this->expectException(QueryException::class); $this->makeEvent($source, $user); } public function test_same_source_ref_with_different_topics_coexist(): void { $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); $user = User::create(['login' => 'nikita', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); $this->makeEvent($source, $user, 'birthday:2026'); $this->makeEvent($source, $user, 'birthday:2027'); $this->assertSame(2, Event::count()); } public function test_casts_round_trip(): void { $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); $user = User::create(['login' => 'nikita', 'tz' => 'Asia/Shanghai', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); $event = $this->makeEvent($source, $user)->fresh(); $this->assertSame(DueMode::Local, $event->due_mode); $this->assertSame(EventState::Scheduled, $event->state); $this->assertSame('2026-08-14', $event->due_date->format('Y-m-d')); $this->assertSame('24:00:00', $event->due_time); $this->assertSame(['title' => 'ДР'], $event->payload); $this->assertFalse($event->isTerminal()); $this->assertSame('Asia/Shanghai', $user->now()->timezoneName); Channel::create(['user_id' => $user->id, 'type' => ChannelType::Web, 'config' => [], 'enabled' => true]); $this->assertSame(ChannelType::Web, $user->channels()->first()->type); } public function test_datetimes_are_stored_as_utc(): void { $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); $user = User::create(['login' => 'nikita', 'tz' => 'Asia/Shanghai', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); $event = Event::create([ 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'person:42', 'topic' => 'birthday:2026', 'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', 'after_due' => AfterDue::Expire, 'payload' => ['title' => 'ДР'], 'state' => EventState::Scheduled, 'quiet_until' => CarbonImmutable::parse('2026-09-10T10:00:00', 'Asia/Shanghai'), ])->fresh(); $this->assertSame('2026-09-10T02:00:00+00:00', $event->quiet_until->utc()->toIso8601String()); } public function test_state_helpers(): void { $this->assertTrue(EventState::Done->isTerminal()); $this->assertTrue(EventState::Expired->isTerminal()); $this->assertTrue(EventState::Withdrawn->isTerminal()); $this->assertFalse(EventState::Today->isTerminal()); $this->assertTrue(EventState::Preparing->isActive()); $this->assertFalse(EventState::Scheduled->isActive()); $this->assertTrue(EventState::Today->countsInBadge()); $this->assertTrue(EventState::Overdue->countsInBadge()); $this->assertFalse(EventState::Preparing->countsInBadge()); $this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues()); $this->assertSame(['today', 'overdue'], EventState::badgeValues()); } }