'UTC']); CarbonImmutable::setTestNow('2026-09-30T11:17:00Z'); $this->source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); } protected function tearDown(): void { CarbonImmutable::setTestNow(); parent::tearDown(); } private function as(string $login = 'nikita'): array { return ['X-Remote-User' => $login]; } private function event(User $user, EventState $state, string $topic = 't', string $due = '2026-09-30', string $fireOn = '2026-09-30'): Event { return Event::create([ 'source_id' => $this->source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic, 'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => '24:00:00', 'fire_on' => $fireOn, 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => $state, 'quiet_until' => CarbonImmutable::now()->subDay(), ]); } public function test_requires_remote_user_header(): void { $this->getJson('/me')->assertStatus(401); } public function test_first_visit_creates_user_with_web_channel(): void { $this->getJson('/me', $this->as())->assertOk() ->assertJsonPath('login', 'nikita') ->assertJsonPath('tz', 'UTC') ->assertJsonPath('quiet_start', '22:00') ->assertJsonPath('quiet_end', '09:00') ->assertJsonPath('badge', 0); $this->assertSame(ChannelType::Web, User::sole()->channels()->sole()->type); } public function test_badge_counts_today_and_overdue_only(): void { $this->getJson('/me', $this->as()); $user = User::sole(); $this->event($user, EventState::Today, 'a'); $this->event($user, EventState::Overdue, 'b'); $this->event($user, EventState::Preparing, 'c'); $this->event($user, EventState::Done, 'd'); $this->getJson('/me', $this->as())->assertJsonPath('badge', 2); } public function test_patch_profile_validates_timezone_and_quiet_hours(): void { $this->patchJson('/me', ['tz' => 'Asia/Shanghai', 'quiet_start' => '23:00', 'quiet_end' => '08:00'], $this->as()) ->assertOk()->assertJsonPath('tz', 'Asia/Shanghai')->assertJsonPath('quiet_start', '23:00'); $this->patchJson('/me', ['tz' => 'Mars/Olympus'], $this->as())->assertStatus(422); $this->patchJson('/me', ['quiet_start' => '08:00', 'quiet_end' => '22:00'], $this->as()) ->assertStatus(422)->assertJsonValidationErrors(['quiet_end']); } public function test_lists_own_non_terminal_events(): void { $this->getJson('/me', $this->as()); $this->getJson('/me', $this->as('other')); $me = User::where('login', 'nikita')->sole(); $other = User::where('login', 'other')->sole(); $this->event($me, EventState::Today, 'a'); $this->event($me, EventState::Done, 'b'); $this->event($other, EventState::Today, 'c'); $this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a'); $this->getJson('/me/events?state=done', $this->as())->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'b'); $this->getJson('/me/events?state=garbage', $this->as())->assertStatus(422); } public function test_ack_sets_quiet_until_and_done_closes(): void { $this->getJson('/me', $this->as()); $user = User::sole(); $today = $this->event($user, EventState::Today, 'a'); $done = $this->event($user, EventState::Preparing, 'b', '2026-10-30', '2026-09-30'); $this->postJson("/me/events/{$today->id}/ack", [], $this->as())->assertOk()->assertJsonPath('state', 'today'); $this->assertSame('2026-09-30T13:00:00+00:00', $today->fresh()->quiet_until->toIso8601String()); $this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk()->assertJsonPath('state', 'done'); $this->assertNotNull($done->fresh()->done_at); $this->postJson("/me/events/{$done->id}/done", [], $this->as())->assertOk(); // идемпотентно } public function test_cannot_touch_someone_elses_event(): void { $this->getJson('/me', $this->as('other')); $event = $this->event(User::sole(), EventState::Today); $this->postJson("/me/events/{$event->id}/done", [], $this->as('nikita'))->assertNotFound(); } public function test_heartbeat_marks_web_presence(): void { $this->postJson('/me/heartbeat', [], $this->as())->assertNoContent(); $this->assertTrue(Cache::has('heartbeat:'.User::sole()->id)); } }