Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
142 lines
7.6 KiB
PHP
142 lines
7.6 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use App\Enums\AfterDue;
|
||
use App\Enums\DueMode;
|
||
use App\Enums\EventState;
|
||
use App\Models\Event;
|
||
use App\Models\Source;
|
||
use App\Models\User;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
class InboxPageTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
config(['hado.default_tz' => '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('<title>(2) Hado</title>', $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('<title>Hado</title>', $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('<div class="body" hidden', $html);
|
||
}
|
||
|
||
public function test_unsafe_deep_link_is_not_rendered_as_href(): void
|
||
{
|
||
$this->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);
|
||
}
|
||
}
|