fix: final review wave — public routes, inbox 419, url schemes, deep_link, tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 09:09:07 -03:00
parent dd714a4be1
commit cbb43714ae
20 changed files with 123 additions and 20 deletions

View File

@@ -40,3 +40,7 @@ POST-ом без авторизации.
`core/Dockerfile` (serversideup/php), compose с `scheduler` и `worker`, Caddy — `core/Dockerfile` (serversideup/php), compose с `scheduler` и `worker`, Caddy —
`deploy/Caddyfile.snippet` в хаб sekai. `deploy/Caddyfile.snippet` в хаб sekai.
Compose в репозитории — dev-стек (`Dockerfile.dev`, `artisan serve`); прод-сборка
через `core/Dockerfile` (serversideup/php) описана, но не проверена — отдельный шаг
перед деплоем.

View File

@@ -36,4 +36,13 @@ enum EventState: string
array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()), array_filter(self::cases(), fn (self $s) => ! $s->isTerminal()),
)); ));
} }
/** @return list<string> */
public static function badgeValues(): array
{
return array_values(array_map(
fn (self $s) => $s->value,
array_filter(self::cases(), fn (self $s) => $s->countsInBadge()),
));
}
} }

View File

@@ -91,7 +91,7 @@ class InboxController extends Controller
/** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */ /** Пропускает только относительные пути и http(s)-ссылки; прочее (напр. javascript:) отбрасывает. */
public static function safeLink(?string $link): ?string public static function safeLink(?string $link): ?string
{ {
return $link !== null && preg_match('#^(/(?!/)|https?://)#i', $link) === 1 ? $link : null; return $link !== null && preg_match('#^(/(?![/\\\\])|https?://)#i', $link) === 1 ? $link : null;
} }
public static function relative(int $diff): string public static function relative(int $diff): string

View File

@@ -31,8 +31,8 @@ class ChannelsController extends Controller
$data = $request->validate([ $data = $request->validate([
'type' => ['required', 'in:webhook'], 'type' => ['required', 'in:webhook'],
'config' => ['required', 'array'], 'config' => ['required', 'array'],
'config.deliver_url' => ['required', 'url'], 'config.deliver_url' => ['required', 'url:http,https'],
'config.presence_url' => ['required', 'url'], 'config.presence_url' => ['required', 'url:http,https'],
], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']); ], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']);
$channel = $user->channels()->create([ $channel = $user->channels()->create([

View File

@@ -10,9 +10,11 @@ use App\Ingest\EventUpserter;
use App\Models\Event; use App\Models\Event;
use App\Models\Source; use App\Models\Source;
use App\Models\User; use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class EventsController extends Controller class EventsController extends Controller
{ {
@@ -22,11 +24,14 @@ class EventsController extends Controller
{ {
/** @var User $user */ /** @var User $user */
$user = $request->attributes->get('user'); $user = $request->attributes->get('user');
$request->validate([
'state' => ['sometimes', Rule::in(array_map(fn ($s) => $s->value, EventState::cases()))],
]);
$states = $request->query('state') $states = $request->query('state')
? [$request->query('state')] ? [$request->query('state')]
: EventState::nonTerminalValues(); : EventState::nonTerminalValues();
$events = $user->events()->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get(); $events = $user->events()->with('source')->whereIn('state', $states)->orderBy('due_date')->orderBy('due_instant')->get();
return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]); return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + ['source' => $e->source->name])->values()]);
} }
@@ -66,7 +71,7 @@ class EventsController extends Controller
'source_ref' => 'manual:'.Str::ulid(), 'source_ref' => 'manual:'.Str::ulid(),
'topic' => '', 'topic' => '',
'due_at' => $dueAt, 'due_at' => $dueAt,
'fire_at' => \Carbon\CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'), 'fire_at' => CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'),
'after_due' => 'keep', 'after_due' => 'keep',
'payload' => array_filter([ 'payload' => array_filter([
'title' => $data['title'], 'title' => $data['title'],

View File

@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator; use Illuminate\Validation\ValidationException;
class ProfileController extends Controller class ProfileController extends Controller
{ {
@@ -33,7 +33,7 @@ class ProfileController extends Controller
$start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5); $start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5);
$end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5); $end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5);
if ($end >= $start) { if ($end >= $start) {
throw \Illuminate\Validation\ValidationException::withMessages([ throw ValidationException::withMessages([
'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start', 'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start',
]); ]);
} }
@@ -64,7 +64,7 @@ class ProfileController extends Controller
'tz' => $user->tz, 'tz' => $user->tz,
'quiet_start' => substr($user->quiet_start, 0, 5), 'quiet_start' => substr($user->quiet_start, 0, 5),
'quiet_end' => substr($user->quiet_end, 0, 5), 'quiet_end' => substr($user->quiet_end, 0, 5),
'badge' => $user->events()->whereIn('state', [EventState::Today->value, EventState::Overdue->value])->count(), 'badge' => $user->events()->whereIn('state', EventState::badgeValues())->count(),
]; ];
} }
} }

View File

@@ -27,7 +27,7 @@ class UpsertEventsRequest extends FormRequest
'events.*.payload' => ['required', 'array'], 'events.*.payload' => ['required', 'array'],
'events.*.payload.title' => ['required', 'string'], 'events.*.payload.title' => ['required', 'string'],
'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'], 'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'],
'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?!/)|https?://)#'], 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string', 'max:2048', 'regex:#^(/(?![/\\\\])|https?://)#i'],
'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'], 'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'],
]; ];
} }

View File

@@ -20,7 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
'auth.source' => AuthenticateSource::class, 'auth.source' => AuthenticateSource::class,
'remote.user' => RemoteUser::class, 'remote.user' => RemoteUser::class,
]); ]);
$middleware->validateCsrfTokens(except: ['a/*', 'hooks/*']); $middleware->validateCsrfTokens(except: ['a/*', 'hooks/*', 'me/heartbeat']);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(

View File

@@ -1,9 +1,13 @@
(() => { (() => {
const csrf = document.querySelector('meta[name=csrf-token]').content; const csrf = document.querySelector('meta[name=csrf-token]').content;
const call = (method, url, body) => fetch(url, { const call = async (method, url, body) => {
const r = await fetch(url, {
method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf }, method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
if (r.status === 419) { location.reload(); return r; }
return r;
};
const showErr = async (el, r) => { const showErr = async (el, r) => {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка'; el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка';
@@ -49,7 +53,10 @@
const card = btn.closest('.card'); const card = btn.closest('.card');
card.querySelectorAll('button').forEach(b => b.disabled = true); card.querySelectorAll('button').forEach(b => b.disabled = true);
const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`); const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false); if (r.ok) location.reload(); else if (r.status !== 419) {
card.querySelectorAll('button').forEach(b => b.disabled = false);
showErr(document.getElementById('card-err'), r);
}
})); }));
// Настройки // Настройки

View File

@@ -90,6 +90,7 @@ footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padd
<button class="btn primary" id="toggle-add">+ Добавить</button> <button class="btn primary" id="toggle-add">+ Добавить</button>
<span class="num">05</span> <span class="num">05</span>
</div> </div>
<div class="err" id="card-err" hidden></div>
</header> </header>
<main> <main>
@@ -161,7 +162,7 @@ footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padd
@foreach($channels as $ch) @foreach($channels as $ch)
<div class="chrow"> <div class="chrow">
<span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span> <span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
<span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }}</span> <span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.($ch['config']['chat_id'] ?? '—') : ($ch['config']['deliver_url'] ?? '—')) }}</span>
@if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif @if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
</div> </div>
@endforeach @endforeach

View File

@@ -6,12 +6,23 @@ use App\Http\Controllers\Me\ChannelsController;
use App\Http\Controllers\Me\EventsController as MeEvents; use App\Http\Controllers\Me\EventsController as MeEvents;
use App\Http\Controllers\Me\ProfileController; use App\Http\Controllers\Me\ProfileController;
use App\Http\Controllers\TelegramWebhookController; use App\Http\Controllers\TelegramWebhookController;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\View\Middleware\ShareErrorsFromSession;
// Публичные: без SSO, без CSRF (см. bootstrap/app.php) // Публичные: без SSO, без CSRF (см. bootstrap/app.php), без сессии
Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack'); Route::withoutMiddleware([
Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done'); StartSession::class,
Route::post('/hooks/telegram', TelegramWebhookController::class); ShareErrorsFromSession::class,
AddQueuedCookiesToResponse::class,
PreventRequestForgery::class,
])->middleware('throttle:60,1')->group(function () {
Route::post('/a/{token}/ack', [ActionController::class, 'ack'])->name('action.ack');
Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done');
Route::post('/hooks/telegram', TelegramWebhookController::class);
});
// Под SSO хаба // Под SSO хаба
Route::middleware('remote.user')->group(function () { Route::middleware('remote.user')->group(function () {

View File

@@ -50,6 +50,11 @@ class ActionLinksTest extends TestCase
$this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String()); $this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String());
} }
public function test_public_route_sets_no_session_cookie(): void
{
$this->postJson('/a/tok/ack')->assertOk()->assertHeaderMissing('Set-Cookie');
}
public function test_done_by_token_then_gone(): void public function test_done_by_token_then_gone(): void
{ {
$this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done'); $this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done');

View File

@@ -38,6 +38,11 @@ class ChannelsApiTest extends TestCase
// telegram подключается только через /start, не через API // telegram подключается только через /start, не через API
$this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as) $this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as)
->assertStatus(422); ->assertStatus(422);
// только http/https-схемы
$this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'ftp://x/y', 'presence_url' => 'ftp://x/y']], $this->as)
->assertStatus(422)->assertJsonValidationErrors(['config.deliver_url', 'config.presence_url']);
$this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'http://home.local/x', 'presence_url' => 'http://home.local/x']], $this->as)
->assertCreated();
} }
public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void

View File

@@ -93,11 +93,18 @@ class ClientApiTest extends TestCase
$this->putJson('/api/events', ['events' => [$protocolRelative]], $this->auth()) $this->putJson('/api/events', ['events' => [$protocolRelative]], $this->auth())
->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']); ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']);
$backslash = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/\evil.com']]);
$this->putJson('/api/events', ['events' => [$backslash]], $this->auth())
->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.deep_link']);
$ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42?x=1']]); $ok1 = $this->item(['payload' => ['title' => 'x', 'deep_link' => '/people/42?x=1']]);
$this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk(); $this->putJson('/api/events', ['events' => [$ok1]], $this->auth())->assertOk();
$ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]); $ok2 = $this->item(['topic' => 'birthday:2027', 'payload' => ['title' => 'x', 'deep_link' => 'https://docs.example/x']]);
$this->putJson('/api/events', ['events' => [$ok2]], $this->auth())->assertOk(); $this->putJson('/api/events', ['events' => [$ok2]], $this->auth())->assertOk();
$ok3 = $this->item(['topic' => 'birthday:2028', 'payload' => ['title' => 'x', 'deep_link' => 'HTTPS://docs.example/x']]);
$this->putJson('/api/events', ['events' => [$ok3]], $this->auth())->assertOk();
} }
public function test_topic_defaults_to_empty_string(): void public function test_topic_defaults_to_empty_string(): void

View File

@@ -80,6 +80,13 @@ class InboxPageTest extends TestCase
$this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html); $this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
$this->assertStringContainsString('/inbox.js', $html); $this->assertStringContainsString('/inbox.js', $html);
$this->assertStringContainsString('csrf-token', $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 public function test_counter_hidden_when_zero(): void
@@ -106,12 +113,20 @@ class InboxPageTest extends TestCase
'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Протокольная ссылка', 'deep_link' => '//evil.com/x'], 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Протокольная ссылка', 'deep_link' => '//evil.com/x'],
'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), '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(); $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
$this->assertStringNotContainsString('href="javascript:', $html); $this->assertStringNotContainsString('href="javascript:', $html);
$this->assertStringNotContainsString('href="//evil', $html); $this->assertStringNotContainsString('href="//evil', $html);
$this->assertStringNotContainsString('href="/\\', $html);
$this->assertStringContainsString('Опасное событие', $html); $this->assertStringContainsString('Опасное событие', $html);
$this->assertStringContainsString('Протокольная ссылка', $html); $this->assertStringContainsString('Протокольная ссылка', $html);
$this->assertStringContainsString('Обратный слэш', $html);
} }
} }

View File

@@ -100,6 +100,7 @@ class MeApiTest extends TestCase
$this->getJson('/me/events', $this->as())->assertOk()->assertJsonCount(1, 'events')->assertJsonPath('events.0.topic', 'a'); $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=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 public function test_ack_sets_quiet_until_and_done_closes(): void

View File

@@ -110,5 +110,6 @@ class SchemaTest extends TestCase
$this->assertTrue(EventState::Overdue->countsInBadge()); $this->assertTrue(EventState::Overdue->countsInBadge());
$this->assertFalse(EventState::Preparing->countsInBadge()); $this->assertFalse(EventState::Preparing->countsInBadge());
$this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues()); $this->assertSame(['scheduled', 'preparing', 'today', 'overdue'], EventState::nonTerminalValues());
$this->assertSame(['today', 'overdue'], EventState::badgeValues());
} }
} }

View File

@@ -43,6 +43,13 @@ class TelegramWebhookTest extends TestCase
$this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403); $this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403);
} }
public function test_public_route_sets_no_session_cookie(): void
{
$this->postJson('/hooks/telegram', ['message' => []])
->assertStatus(403)
->assertHeaderMissing('Set-Cookie');
}
public function test_start_with_code_links_chat(): void public function test_start_with_code_links_chat(): void
{ {
Cache::put('tg:link:ABC123', $this->user->id, 900); Cache::put('tg:link:ABC123', $this->user->id, 900);

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Channels\ChannelRegistry; use App\Channels\ChannelRegistry;
use App\Delivery\DeliverJob;
use App\Delivery\Tick; use App\Delivery\Tick;
use App\Enums\AfterDue; use App\Enums\AfterDue;
use App\Enums\ChannelType; use App\Enums\ChannelType;
@@ -20,6 +21,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Tests\Support\FakeChannel; use Tests\Support\FakeChannel;
use Tests\Support\FakeRegistry; use Tests\Support\FakeRegistry;
use Tests\TestCase; use Tests\TestCase;
@@ -29,9 +31,13 @@ class TickTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
private User $user; private User $user;
private Source $source; private Source $source;
private FakeChannel $web; private FakeChannel $web;
private FakeChannel $telegram; private FakeChannel $telegram;
private FakeChannel $webhook; private FakeChannel $webhook;
protected function setUp(): void protected function setUp(): void
@@ -105,6 +111,18 @@ class TickTest extends TestCase
$this->assertSame(2, Delivery::count(), 'новое окно — новая доставка'); $this->assertSame(2, Delivery::count(), 'новое окно — новая доставка');
} }
public function test_pending_row_exists_before_job_runs(): void
{
Queue::fake();
$this->event('2026-09-30', '2026-08-31');
$this->webhook->presence = Presence::Present;
$this->tickAt('2026-09-05T10:00:00Z');
$this->assertSame(DeliveryResult::Pending, Delivery::sole()->result);
Queue::assertPushed(DeliverJob::class, 1);
}
public function test_falls_back_to_all_channels_near_window_end(): void public function test_falls_back_to_all_channels_near_window_end(): void
{ {
$this->event('2026-09-30', '2026-08-31'); $this->event('2026-09-30', '2026-08-31');

View File

@@ -195,6 +195,9 @@ PUT /api/events
`HADO_RETENTION_DAYS` (по умолчанию 90) после перехода в терминальное состояние, `HADO_RETENTION_DAYS` (по умолчанию 90) после перехода в терминальное состояние,
чтобы вхождения не копились вечно. чтобы вхождения не копились вечно.
Реализация считает срок от `updated_at`; повторный upsert закрытого события
продлевает хранение — допустимое приближение.
### Снятие и чтение ### Снятие и чтение
``` ```
@@ -467,6 +470,10 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`. (`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
Compose в репозитории — dev-стек (`Dockerfile.dev`, `artisan serve`); прод-сборка
через `core/Dockerfile` (serversideup/php) описана, но не проверена — отдельный шаг
перед деплоем.
## 13. Тесты ## 13. Тесты
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы. Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.