setPdo( DB::connection('pgsql')->getPdo() ); DB::connection('pgsql_supplier')->setReadPdo( DB::connection('pgsql')->getReadPdo() ); }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Получить строку heartbeat через default connection. * В test-env оба pgsql + pgsql_supplier указывают на liderra_testing. */ function getHeartbeat(string $name): ?object { return DB::table('scheduler_heartbeats') ->where('command_name', $name) ->first(); } function insertHeartbeat(array $data): void { $defaults = [ 'last_run_at' => null, 'last_success_at' => null, 'last_error' => null, 'runtime_ms' => null, 'consecutive_failures' => 0, 'created_at' => now(), 'updated_at' => now(), ]; DB::table('scheduler_heartbeats')->insert(array_merge($defaults, $data)); } /** * Гарантирует наличие активного saas_admin_user для FK incidents_log. * Паттерн из IncidentsWatchFailuresTest::ensureSystemAdmin(). */ function ensureHeartbeatAdmin(): int { $id = DB::table('saas_admin_users')->where('is_active', true)->whereNull('deleted_at')->value('id'); if ($id !== null) { return (int) $id; } return (int) DB::table('saas_admin_users')->insertGetId([ 'email' => 'hb-check-admin@liderra.ru', 'full_name' => 'Heartbeat Check Admin', 'password_hash' => '$2y$12$placeholder', 'is_active' => true, 'role' => 'support', 'created_at' => now(), ]); } // --------------------------------------------------------------------------- // SchedulerHeartbeatTracker::recordRun — успешный запуск // --------------------------------------------------------------------------- it('recordRun обновляет last_run_at и last_success_at при успехе', function (): void { $tracker = app(SchedulerHeartbeatTracker::class); $before = now()->subSecond(); $tracker->recordRun('test:success', fn () => null); $row = getHeartbeat('test:success'); expect($row)->not->toBeNull('строка heartbeat не создана') ->and(Carbon::parse($row->last_run_at))->toBeGreaterThan($before) ->and(Carbon::parse($row->last_success_at))->toBeGreaterThan($before) ->and($row->consecutive_failures)->toBe(0) ->and($row->last_error)->toBeNull(); }); it('recordRun сбрасывает consecutive_failures до 0 после успеха', function (): void { // Создаём строку с ненулевыми consecutive_failures insertHeartbeat([ 'command_name' => 'test:reset-failures', 'consecutive_failures' => 5, 'last_error' => 'prev error', ]); $tracker = app(SchedulerHeartbeatTracker::class); $tracker->recordRun('test:reset-failures', fn () => null); $row = getHeartbeat('test:reset-failures'); expect($row->consecutive_failures)->toBe(0) ->and($row->last_error)->toBeNull(); }); // --------------------------------------------------------------------------- // SchedulerHeartbeatTracker::recordRun — исключение // --------------------------------------------------------------------------- it('recordRun обновляет last_error и инкрементирует consecutive_failures при exception', function (): void { $tracker = app(SchedulerHeartbeatTracker::class); $before = now()->subSecond(); $thrown = false; try { $tracker->recordRun('test:fail', function (): never { throw new RuntimeException('test error message'); }); } catch (RuntimeException) { $thrown = true; } expect($thrown)->toBeTrue('исключение должно пробрасываться'); $row = getHeartbeat('test:fail'); expect($row)->not->toBeNull('строка heartbeat не создана') ->and(Carbon::parse($row->last_run_at))->toBeGreaterThan($before) ->and($row->last_success_at)->toBeNull() ->and($row->last_error)->toContain('test error message') ->and($row->consecutive_failures)->toBe(1); }); it('recordRun инкрементирует consecutive_failures накопительно', function (): void { insertHeartbeat([ 'command_name' => 'test:multi-fail', 'consecutive_failures' => 2, ]); $tracker = app(SchedulerHeartbeatTracker::class); try { $tracker->recordRun('test:multi-fail', function (): never { throw new RuntimeException('again'); }); } catch (RuntimeException) { } $row = getHeartbeat('test:multi-fail'); expect($row->consecutive_failures)->toBe(3); }); // --------------------------------------------------------------------------- // SchedulerCheckHeartbeats — детекция пропавшего пульса // --------------------------------------------------------------------------- it('SchedulerCheckHeartbeats флагует команду с last_run_at старше 2× интервала', function (): void { Mail::fake(); ensureHeartbeatAdmin(); // Команда incidents:watch-failures: интервал 10 минут → старше 20 мин = флаг insertHeartbeat([ 'command_name' => 'incidents:watch-failures', 'last_run_at' => now()->subMinutes(25), 'last_success_at' => now()->subMinutes(25), ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk()->run(); $incident = DB::table('incidents_log') ->where('summary', 'like', '%incidents:watch-failures%') ->first(); expect($incident)->not->toBeNull('incident не создан для пропавшего пульса') ->and($incident->severity)->toBe('high'); }); it('SchedulerCheckHeartbeats флагует команду с consecutive_failures >= 3', function (): void { Mail::fake(); ensureHeartbeatAdmin(); insertHeartbeat([ 'command_name' => 'supplier:retry-failed', 'last_run_at' => now()->subMinutes(5), 'last_success_at' => now()->subMinutes(65), 'consecutive_failures' => 3, ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $incident = DB::table('incidents_log') ->where('summary', 'like', '%supplier:retry-failed%') ->first(); expect($incident)->not->toBeNull('incident не создан при consecutive_failures=3'); }); // --------------------------------------------------------------------------- // Dedup — повторный запуск не дублирует инцидент // --------------------------------------------------------------------------- it('SchedulerCheckHeartbeats не дублирует incident при повторном запуске', function (): void { Mail::fake(); ensureHeartbeatAdmin(); insertHeartbeat([ 'command_name' => 'audit:verify-chains', 'last_run_at' => now()->subDays(3), 'last_success_at' => now()->subDays(3), ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $count = DB::table('incidents_log') ->where('summary', 'like', '%audit:verify-chains%') ->count(); expect($count)->toBe(1, 'инцидент задублирован — dedup не работает'); }); // --------------------------------------------------------------------------- // Mailable отправляется // --------------------------------------------------------------------------- it('SchedulerCheckHeartbeats отправляет SchedulerHeartbeatMissingMail', function (): void { Mail::fake(); ensureHeartbeatAdmin(); insertHeartbeat([ 'command_name' => 'partitions:create-months', 'last_run_at' => now()->subHours(50), 'last_success_at' => now()->subHours(50), ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); Mail::assertSent(SchedulerHeartbeatMissingMail::class, function ($mail) { return $mail->hasTo('kdv1@bk.ru'); }); }); // --------------------------------------------------------------------------- // Свежая команда в расписании — срок ещё не наступил // // Живой случай 07.08.2026: выкат в 12:04 добавил три ежедневные команды // (obzvon:chistka 03:45, obzvon:chistka-materialov 03:50, lena:proverka-obhoda // 06:10). Их время в этот день уже прошло, записи пульса не было, и часовой // сторож с окном дедупа 60 мин заводил три жалобы КАЖДЫЙ ЧАС до утра. Ровно так // же 05–06.08 набежало 18 записей про incidents:watch-failures. // --------------------------------------------------------------------------- it('SchedulerCheckHeartbeats молчит про команду, впервые появившуюся в расписании', function (): void { Mail::fake(); ensureHeartbeatAdmin(); // Свежий выкат: команда в расписании есть, записи пульса нет вовсе. DB::table('scheduler_heartbeats')->where('command_name', 'obzvon:chistka')->delete(); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $incident = DB::table('incidents_log') ->whereNull('resolved_at') ->where('summary', 'like', 'Scheduler heartbeat: obzvon:chistka —%') ->first(); expect($incident)->toBeNull('пожаловался на команду, чьё время по расписанию ещё не наступило'); $row = getHeartbeat('obzvon:chistka'); expect($row)->not->toBeNull('не заведена отметка «команда известна с этого мгновения»') ->and($row->last_run_at)->toBeNull('отметка не должна выдавать несостоявшийся запуск за состоявшийся'); }); it('SchedulerCheckHeartbeats молчит, пока не вышел срок первого запуска', function (): void { Mail::fake(); ensureHeartbeatAdmin(); // Отметка заведена час назад, команда ежедневная (1440 мин) — срок не вышел. insertHeartbeat([ 'command_name' => 'obzvon:chistka-materialov', 'last_run_at' => null, 'created_at' => now()->subHour(), ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $incident = DB::table('incidents_log') ->where('summary', 'like', 'Scheduler heartbeat: obzvon:chistka-materialov —%') ->first(); expect($incident)->toBeNull('пожаловался раньше срока первого запуска'); }); it('SchedulerCheckHeartbeats жалуется, если команда так и не запустилась за отведённый срок', function (): void { Mail::fake(); ensureHeartbeatAdmin(); // Отметка заведена трое суток назад, ежедневная команда так и не отработала. // Это граница: молчание не должно стать вечным. insertHeartbeat([ 'command_name' => 'lena:proverka-obhoda', 'last_run_at' => null, 'created_at' => now()->subDays(3), ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $incident = DB::table('incidents_log') ->where('summary', 'like', 'Scheduler heartbeat: lena:proverka-obhoda —%') ->first(); expect($incident)->not->toBeNull('промолчал про команду, которая давно должна была отработать'); }); // --------------------------------------------------------------------------- // Сторож ожил — жалоба закрывается сама // // Без этого записи копятся навсегда: 06.08 сторож починился в 20:37, а 18 жалоб // про него остались открытыми, и лампа «Здоровье портала» горела красным сутки // после устранения причины. Кнопки «закрыть» в админке нет. // --------------------------------------------------------------------------- it('SchedulerCheckHeartbeats закрывает свою жалобу, когда команда ожила', function (): void { Mail::fake(); $adminId = ensureHeartbeatAdmin(); // Команда здорова: только что отработала, ошибок подряд нет. insertHeartbeat([ 'command_name' => 'incidents:watch-failures', 'last_run_at' => now()->subMinute(), 'last_success_at' => now()->subMinute(), 'consecutive_failures' => 0, ]); // ...а с прошлой аварии висит открытая жалоба на неё. DB::table('incidents_log')->insert([ 'type' => 'other', 'severity' => 'high', 'summary' => "Scheduler heartbeat: incidents:watch-failures — Команда 'incidents:watch-failures' завершилась с ошибкой 189 раз подряд.", 'started_at' => now()->subDay(), 'detected_at' => now()->subDay(), 'resolved_at' => null, 'created_by_admin_id' => $adminId, 'created_at' => now()->subDay(), 'updated_at' => now()->subDay(), ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $incident = DB::table('incidents_log') ->where('summary', 'like', 'Scheduler heartbeat: incidents:watch-failures —%') ->orderByDesc('id') ->first(); expect($incident->resolved_at)->not->toBeNull('жалоба осталась открытой, хотя сторож ожил'); }); it('SchedulerCheckHeartbeats не закрывает жалобу на команду, которая всё ещё больна', function (): void { Mail::fake(); $adminId = ensureHeartbeatAdmin(); // Команда по-прежнему падает. insertHeartbeat([ 'command_name' => 'supplier:retry-failed', 'last_run_at' => now()->subMinutes(5), 'last_success_at' => now()->subMinutes(65), 'consecutive_failures' => 7, ]); DB::table('incidents_log')->insert([ 'type' => 'other', 'severity' => 'high', 'summary' => "Scheduler heartbeat: supplier:retry-failed — Команда 'supplier:retry-failed' завершилась с ошибкой 5 раз подряд.", 'started_at' => now()->subHours(3), 'detected_at' => now()->subHours(3), 'resolved_at' => null, 'created_by_admin_id' => $adminId, 'created_at' => now()->subHours(3), 'updated_at' => now()->subHours(3), ]); $this->artisan(SchedulerCheckHeartbeats::class)->assertOk(); $otkrytyh = DB::table('incidents_log') ->whereNull('resolved_at') ->where('summary', 'like', 'Scheduler heartbeat: supplier:retry-failed —%') ->count(); expect($otkrytyh)->toBeGreaterThan(0, 'закрыл жалобу на команду, которая всё ещё падает'); });