'UTC']);
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
parent::tearDown();
}
public function test_requires_sso(): void
{
$this->get('/')->assertStatus(401);
}
public function test_renders_groups_cards_archive_and_settings(): void
{
$this->get('/', ['X-Remote-User' => 'nikita']);
$user = User::sole();
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
$manual = Source::create(['name' => 'manual', 'token_hash' => hash('sha256', 'y')]);
$make = fn (Source $src, string $topic, EventState $state, string $due, string $time = '24:00:00', ?string $doneLabel = null, ?string $link = '/x') => Event::create([
'source_id' => $src->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $due,
'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'subtitle' => 'подзаголовок', 'deep_link' => $link, 'done_label' => $doneLabel]),
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
]);
$make($source, 'a', EventState::Today, '2026-09-04', '19:40:00', 'Поздравил');
$make($source, 'b', EventState::Overdue, '2026-09-01');
$make($manual, 'c', EventState::Preparing, '2026-09-30', '24:00:00', null, null);
$make($source, 'd', EventState::Scheduled, '2026-12-15');
$make($source, 'e', EventState::Done, '2026-08-28', '24:00:00', 'Оплатил');
$make($source, 'f', EventState::Expired, '2026-08-20');
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
// шапка и счётчик
$this->assertStringContainsString('
(2) Hado', $html);
$this->assertStringContainsString('波動', $html);
$this->assertStringContainsString('+ Добавить', $html);
// группы в порядке и с кандзи
$this->assertMatchesRegularExpression('/今日.*СЕГОДНЯ.*遅延.*ПРОСРОЧЕНО.*近日.*ГРЯДЁТ.*予定.*ЗАПЛАНИРОВАНО/su', $html);
// карточки
$this->assertStringContainsString('Событие a', $html);
$this->assertStringContainsString('19:40', $html);
$this->assertStringContainsString('СЕН', $html);
$this->assertStringContainsString('сегодня', $html);
$this->assertStringContainsString('3 дня назад', $html);
$this->assertStringContainsString('через 26 дней', $html);
$this->assertStringContainsString('вручную', $html);
$this->assertStringContainsString('Поздравил', $html);
$this->assertStringContainsString('Помню', $html);
// архив
$this->assertStringContainsString('2 закрыто', $html);
$this->assertStringContainsString('Оплатил', $html);
$this->assertStringContainsString('Истекло', $html);
// настройки, форма добавления, присутствие
$this->assertStringContainsString('nikita', $html);
$this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
$this->assertStringContainsString('/inbox.js', $html);
$this->assertStringContainsString('csrf-token', $html);
// инлайн-ошибка карточки и обработка 419 в клиентском скрипте
$this->assertStringContainsString('card-err', $html);
$this->assertStringContainsString('419', file_get_contents(public_path('inbox.js')));
// Запланировано — витрина, без действий «Помню»/«Сделано»
$this->assertStringContainsString('>Помню<', $html);
$scheduledSection = substr($html, (int) strpos($html, '予定'));
$this->assertStringNotContainsString('>Помню<', $scheduledSection);
}
public function test_counter_hidden_when_zero(): void
{
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
$this->assertStringContainsString('Hado', $html);
$this->assertStringNotContainsString('class="counter"', $html);
}
public function test_hidden_attribute_beats_flex_display_rules(): void
{
// .panel .body{display:flex} иначе перебивает браузерное [hidden]{display:none},
// и свёрнутые панели архива/настроек показываются всегда.
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
$this->assertStringContainsString('[hidden]{display:none!important}', $html);
$this->assertStringContainsString('get('/', ['X-Remote-User' => 'nikita']);
$user = User::sole();
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
Event::create([
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'a',
'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04',
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Опасное событие', 'deep_link' => 'javascript:alert(1)'],
'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
]);
Event::create([
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'b',
'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04',
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Протокольная ссылка', 'deep_link' => '//evil.com/x'],
'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
]);
Event::create([
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'c',
'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04',
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Обратный слэш', 'deep_link' => '/\evil.com'],
'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
]);
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
$this->assertStringNotContainsString('href="javascript:', $html);
$this->assertStringNotContainsString('href="//evil', $html);
$this->assertStringNotContainsString('href="/\\', $html);
$this->assertStringContainsString('Опасное событие', $html);
$this->assertStringContainsString('Протокольная ссылка', $html);
$this->assertStringContainsString('Обратный слэш', $html);
}
}