c351b0b6f5
Дверь принимала итог, который сам себе противоречил, и записывала «всё хорошо». Годный по форме случай: тело говорит «сторожей 3, красных 2», а в списке лежит одна зелёная строка — двое сторожей исчезали молча. Дыры было две: - длина списка storozha ни с чем не сверялась; - поле storozhey_krasnyh не читалось вовсе — машина присылала своё показание, а дверь его выбрасывала. Теперь сверяются три показания о числе сторожей — настройка, объявленное число и длина списка — плюс объявленное число красных против нашего подсчёта по строкам. Красных считаем ровно по машинному правилу: код не ноль или провалов не ноль. Иначе зелёный сторож с нулём проверок давал бы ложную тревогу, потому что портал ругается на такого, а машина его красным не считает. Сверх заказанного закрыты два места того же рода. Поля, которых дверь не знает, ищутся вычитанием, а не перечислением — и в итоге, и в строке сторожа: тогда забытое поле оказывается под защитой, а не мимо неё. И ловится список нужной длины, набитый повторами одного имени: числа при этом сходятся все до одного, а гоняли одного сторожа трижды. Каждая новая проверка показана красной вырезанием своего куска по одному за раз. Без чтения storozhey_krasnyh случай с объявленной краснотой при трёх зелёных строках не ловит ничто — список бед выходит пустым. Машину не трогал, схему не трогал, остальную дверь не переписывал. Полный прогон: всего 5207, зелёных 5203, красных 0, ошибок 0, пропущено 4.
442 lines
21 KiB
PHP
442 lines
21 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Mail\LenaStorozhaTrevogaMail;
|
||
use App\Services\SchedulerHeartbeatTracker;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Illuminate\Testing\TestResponse;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||
|
||
const SLOVO = 'слово-для-пробы-не-короче-тридцати-двух-знаков-0123456789';
|
||
|
||
beforeEach(function (): void {
|
||
DB::connection('pgsql_supplier')->setPdo(DB::connection('pgsql')->getPdo());
|
||
DB::connection('pgsql_supplier')->setReadPdo(DB::connection('pgsql')->getReadPdo());
|
||
|
||
config()->set('storozha_mashiny.tainoe_slovo', SLOVO);
|
||
config()->set('storozha_mashiny.adresa', '');
|
||
config()->set('storozha_mashiny.ozhidaem_storozhey', 3);
|
||
config()->set('storozha_mashiny.svezhest_chasov', 26);
|
||
config()->set('storozha_mashiny.imya_otmetki', 'lena:obhod-storozhey');
|
||
config()->set('services.monitoring.alert_email', 'ops@liderra.ru');
|
||
|
||
Mail::fake();
|
||
});
|
||
|
||
// ---------------------------------------------------------------- помощники --
|
||
|
||
/**
|
||
* Итог ровно такой формы, какую пишет sutochnyy-obhod.sh на машине.
|
||
* Числа проверок — те самые, что сторожа дают сегодня: 26 / 46 / 27.
|
||
*/
|
||
function itogSTremyaZelenymi(?string $sobranoV = null): string
|
||
{
|
||
return (string) json_encode([
|
||
'shema' => 1,
|
||
'sobrano_v' => $sobranoV ?? now()->toIso8601String(),
|
||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||
'dlilos_ms' => 42000,
|
||
'storozhey_vsego' => 3,
|
||
'storozhey_krasnyh' => 0,
|
||
'slepok_etalonov' => str_repeat('a', 64),
|
||
'storozha' => [
|
||
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
|
||
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
|
||
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
|
||
],
|
||
], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
function postuchat(string $telo, ?string $podpis = null): TestResponse
|
||
{
|
||
$podpis ??= 'sha256='.hash_hmac('sha256', $telo, SLOVO);
|
||
|
||
return test()->call(
|
||
'POST',
|
||
'/api/webhook/lena-storozha',
|
||
[], [], [],
|
||
['CONTENT_TYPE' => 'application/json', 'HTTP_X_STOROZH_PODPIS' => $podpis],
|
||
$telo,
|
||
);
|
||
}
|
||
|
||
function otmetka(): ?object
|
||
{
|
||
return DB::table('scheduler_heartbeats')->where('command_name', 'lena:obhod-storozhey')->first();
|
||
}
|
||
|
||
function polozhitOtmetku(array $polya): void
|
||
{
|
||
DB::table('scheduler_heartbeats')->insert(array_merge([
|
||
'command_name' => 'lena:obhod-storozhey',
|
||
'last_run_at' => null,
|
||
'last_success_at' => null,
|
||
'last_error' => null,
|
||
'runtime_ms' => 1000,
|
||
'consecutive_failures' => 0,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
], $polya));
|
||
}
|
||
|
||
// ============================================================ ПРИЁМ СТУКА ====
|
||
|
||
it('принимает подписанный зелёный итог и кладёт отметку', function (): void {
|
||
$otvet = postuchat(itogSTremyaZelenymi());
|
||
|
||
$otvet->assertStatus(202);
|
||
$o = otmetka();
|
||
expect($o)->not->toBeNull()
|
||
->and($o->last_error)->toBeNull()
|
||
->and((int) $o->consecutive_failures)->toBe(0);
|
||
});
|
||
|
||
it('🔴 считает возраст по дате ИЗ ИТОГА, а не по времени получения', function (): void {
|
||
// Случай мимо допущения «стук дошёл — значит сторожа отработали»: сторожа
|
||
// не запускались, а стук отправил ВЧЕРАШНИЙ файл. Отметка обязана взять
|
||
// вчерашнюю дату, иначе портал считал бы всё свежим вечно.
|
||
$vchera = now()->subHours(30);
|
||
postuchat(itogSTremyaZelenymi($vchera->toIso8601String()))->assertStatus(202);
|
||
|
||
// 🪤 Сравниваем по модулю и в секундах. Первый заход этого сторожа стоял на
|
||
// голом diffInMinutes(), а он у Carbon 3 знаковый: разница выходила -1800
|
||
// и «меньше двух» проходило ВСЕГДА. Сторож был зелёным и не сторожил
|
||
// ничего — поймано только тем, что его показали красным на подсунутой
|
||
// ошибке (подмена sobrano_v на now() в контроллере).
|
||
$o = otmetka();
|
||
expect(abs(Carbon::parse($o->last_run_at)->getTimestamp() - $vchera->getTimestamp()))
|
||
->toBeLessThan(2);
|
||
});
|
||
|
||
it('гонит стук с негодной подписью', function (): void {
|
||
postuchat(itogSTremyaZelenymi(), 'sha256='.str_repeat('f', 64))->assertStatus(404);
|
||
expect(otmetka())->toBeNull();
|
||
});
|
||
|
||
it('гонит стук вовсе без подписи', function (): void {
|
||
$telo = itogSTremyaZelenymi();
|
||
test()->call('POST', '/api/webhook/lena-storozha', [], [], [], ['CONTENT_TYPE' => 'application/json'], $telo)
|
||
->assertStatus(404);
|
||
expect(otmetka())->toBeNull();
|
||
});
|
||
|
||
it('дорога закрыта, пока тайное слово не задано', function (): void {
|
||
config()->set('storozha_mashiny.tainoe_slovo', '');
|
||
postuchat(itogSTremyaZelenymi(), 'sha256='.hash_hmac('sha256', itogSTremyaZelenymi(), ''))
|
||
->assertStatus(404);
|
||
expect(otmetka())->toBeNull();
|
||
});
|
||
|
||
it('гонит стук с чужого адреса, когда список задан', function (): void {
|
||
config()->set('storozha_mashiny.adresa', '51.250.1.97, 10.0.0.0/8');
|
||
postuchat(itogSTremyaZelenymi())->assertStatus(404);
|
||
expect(otmetka())->toBeNull();
|
||
});
|
||
|
||
it('🔴 называет ИМЯ покрасневшего сторожа', function (): void {
|
||
$telo = (string) json_encode([
|
||
'shema' => 1,
|
||
'sobrano_v' => now()->toIso8601String(),
|
||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||
'dlilos_ms' => 40000,
|
||
'storozhey_vsego' => 3,
|
||
'storozhey_krasnyh' => 1,
|
||
'slepok_etalonov' => str_repeat('b', 64),
|
||
'storozha' => [
|
||
['imya' => 'proverka-raspisaniya', 'kod' => 1, 'proverok' => 26, 'provalov' => 3],
|
||
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
|
||
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
|
||
],
|
||
], JSON_UNESCAPED_UNICODE);
|
||
|
||
postuchat($telo)->assertStatus(202);
|
||
|
||
$o = otmetka();
|
||
expect($o->last_error)->toContain('proverka-raspisaniya')
|
||
->and($o->last_error)->not->toContain('proverka-chistki')
|
||
->and((int) $o->consecutive_failures)->toBe(1);
|
||
});
|
||
|
||
it('🔴 краснеет, если сторожей стало больше, чем мы ждём', function (): void {
|
||
// Случай мимо допущения «сторожей три»: на машину положили четвёртого,
|
||
// а портал по-прежнему ждёт троих. Молчать нельзя.
|
||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||
$itog['storozhey_vsego'] = 4;
|
||
$itog['storozha'][] = ['imya' => 'proverka-zagotovok', 'kod' => 0, 'proverok' => 23, 'provalov' => 0];
|
||
|
||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||
|
||
expect(otmetka()->last_error)->toContain('сторожей в обходе 4, а ждём 3');
|
||
});
|
||
|
||
it('краснеет на зелёном стороже, не сделавшем ни одной проверки', function (): void {
|
||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||
$itog['storozha'][1]['proverok'] = 0;
|
||
|
||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||
|
||
expect(otmetka()->last_error)->toContain('ничего не проверил');
|
||
});
|
||
|
||
it('краснеет на дате сборки из будущего', function (): void {
|
||
postuchat(itogSTremyaZelenymi(now()->addDays(3)->toIso8601String()))->assertStatus(202);
|
||
|
||
expect(otmetka()->last_error)->toContain('в будущем');
|
||
});
|
||
|
||
it('краснеет и протухает, когда даты сборки нет вовсе', function (): void {
|
||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||
unset($itog['sobrano_v']);
|
||
|
||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||
|
||
$o = otmetka();
|
||
expect($o->last_error)->toContain('дата сборки итога непонятна')
|
||
->and(Carbon::parse($o->last_run_at)->year)->toBe(1970);
|
||
});
|
||
|
||
it('краснеет на неизвестной разновидности итога', function (): void {
|
||
postuchat((string) json_encode(['shema' => 99, 'sobrano_v' => now()->toIso8601String()]))
|
||
->assertStatus(202);
|
||
|
||
expect(otmetka()->last_error)->toContain('непонятная разновидность');
|
||
});
|
||
|
||
// ======================================= СВЯЗНОСТЬ ТОГО, ЧТО ПРИНЯЛИ ========
|
||
//
|
||
// 🔴 Нож владельца по допущению, на котором стояла защита: «список storozha
|
||
// ПОЛОН, поэтому красноту можно пересчитать по нему, а собственное число
|
||
// красных из итога читать незачем». Оба случая ниже годны по форме —
|
||
// разновидность 1, дата свежая, подпись верна, адрес разрешён — и оба
|
||
// проходили насквозь: портал записывал «всё хорошо», хотя машина в том же
|
||
// теле прямо сказала обратное.
|
||
|
||
it('🔴 краснеет, когда в списке сторожей МЕНЬШЕ строк, чем объявлено', function (): void {
|
||
// Случай Б ножа владельца: объявлено трое, в списке ОДИН зелёный.
|
||
// Двое сторожей просто исчезли, и до починки портал молчал.
|
||
$telo = (string) json_encode([
|
||
'shema' => 1,
|
||
'sobrano_v' => now()->toIso8601String(),
|
||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||
'dlilos_ms' => 41000,
|
||
'storozhey_vsego' => 3,
|
||
'storozhey_krasnyh' => 2,
|
||
'slepok_etalonov' => str_repeat('c', 64),
|
||
'storozha' => [
|
||
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
|
||
],
|
||
], JSON_UNESCAPED_UNICODE);
|
||
|
||
postuchat($telo)->assertStatus(202);
|
||
|
||
// 🪤 Приводим к строке нарочно. На голом toContain() непочиненная дверь даёт
|
||
// last_error = NULL, и Pest падает «Expected [iterable]» — по такому
|
||
// красному не видно, дыра это или сторож сам сломан. Со строкой красный
|
||
// честный: «'' не содержит ...».
|
||
$o = otmetka();
|
||
expect((string) $o->last_error)->toContain('строк в списке сторожей 1, а объявлено 3');
|
||
expect($o->last_success_at)->toBeNull()
|
||
->and((int) $o->consecutive_failures)->toBe(1);
|
||
});
|
||
|
||
it('🔴 краснеет, когда объявленное число красных не сходится со списком', function (): void {
|
||
// Случай В ножа владельца: машина объявила «красных 3», а все три строки
|
||
// зелёные. Портал обязан заметить, что итог сам себе противоречит, а не
|
||
// выбрасывать показание, которое ему дали.
|
||
$telo = (string) json_encode([
|
||
'shema' => 1,
|
||
'sobrano_v' => now()->toIso8601String(),
|
||
'mashina' => 'fhmg5qpo4jconna4s9jo',
|
||
'dlilos_ms' => 41000,
|
||
'storozhey_vsego' => 3,
|
||
'storozhey_krasnyh' => 3,
|
||
'slepok_etalonov' => str_repeat('d', 64),
|
||
'storozha' => [
|
||
['imya' => 'proverka-raspisaniya', 'kod' => 0, 'proverok' => 26, 'provalov' => 0],
|
||
['imya' => 'proverka-tekstovogo-sleda', 'kod' => 0, 'proverok' => 46, 'provalov' => 0],
|
||
['imya' => 'proverka-chistki', 'kod' => 0, 'proverok' => 27, 'provalov' => 0],
|
||
],
|
||
], JSON_UNESCAPED_UNICODE);
|
||
|
||
postuchat($telo)->assertStatus(202);
|
||
|
||
expect((string) otmetka()->last_error)->toContain('красных по списку 0, а объявлено 3');
|
||
});
|
||
|
||
it('🔴 контроль ножа: связный зелёный итог остаётся зелёным', function (): void {
|
||
// Случай А ножа владельца. Без него две проверки выше доказывали бы только
|
||
// то, что дверь научилась ругаться, — а не то, что она ругается по делу.
|
||
$otvet = postuchat(itogSTremyaZelenymi());
|
||
|
||
$otvet->assertStatus(202)->assertJson(['status' => 'принят', 'bedy' => []]);
|
||
expect(otmetka()->last_error)->toBeNull();
|
||
});
|
||
|
||
it('🔴 краснеет на поле, о котором дверь не знает', function (): void {
|
||
// Список «под защитой» считается ВЫЧИТАНИЕМ: всё, чего дверь не знает
|
||
// поимённо, — новость, о которой надо сказать вслух. Иначе поле, добавленное
|
||
// на машине и забытое здесь, окажется мимо защиты, а не под ней.
|
||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||
$itog['storozhey_propushcheno'] = 2;
|
||
|
||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||
|
||
expect((string) otmetka()->last_error)->toContain('storozhey_propushcheno');
|
||
});
|
||
|
||
it('🔴 краснеет на поле строки сторожа, о котором дверь не знает', function (): void {
|
||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||
$itog['storozha'][1]['propushcheno'] = 4;
|
||
|
||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||
|
||
expect((string) otmetka()->last_error)->toContain('propushcheno');
|
||
});
|
||
|
||
it('🔴 краснеет на список нужной длины, набитый одним и тем же сторожем', function (): void {
|
||
// Второе место того же рода, о котором нож владельца не говорил: длину
|
||
// списка можно добрать повторами. Тогда числа сходятся все до одного, а
|
||
// на деле гоняли одного сторожа трижды.
|
||
$itog = json_decode(itogSTremyaZelenymi(), true);
|
||
$itog['storozha'][1]['imya'] = 'proverka-raspisaniya';
|
||
$itog['storozha'][2]['imya'] = 'proverka-raspisaniya';
|
||
|
||
postuchat((string) json_encode($itog, JSON_UNESCAPED_UNICODE))->assertStatus(202);
|
||
|
||
expect((string) otmetka()->last_error)->toContain('повторяются имена');
|
||
});
|
||
|
||
it('🔴 разбирает НАСТОЯЩЕЕ тело, снятое с машины, а не только своё выдуманное', function (): void {
|
||
// 🪤 Шов между двумя честными половинками. Все проверки выше кормят портал
|
||
// телом, которое сами же и составили. Так можно доказать что угодно и
|
||
// не заметить, что машина шлёт другое. Это тело снято 06.08.2026 с
|
||
// настоящего обхода на машине 51.250.1.97 — байт в байт то, что дошло
|
||
// до подставного слушателя на ней (проверено cmp) вместе с подписью.
|
||
$telo = <<<'JSON'
|
||
{
|
||
"shema": 1,
|
||
"sobrano_v": "2026-08-06T18:42:52Z",
|
||
"mashina": "fhmg5qpo4jconna4s9jo",
|
||
"dlilos_ms": 2000,
|
||
"storozhey_vsego": 3,
|
||
"storozhey_krasnyh": 0,
|
||
"slepok_etalonov": "df0eedf9be30e6e5f81ecd1d7f4ef46ef0901e1d4563230b79b0778ef6236b23",
|
||
"storozha": [
|
||
{"imya": "proverka-chistki", "kod": 0, "proverok": 27, "provalov": 0},
|
||
{"imya": "proverka-raspisaniya", "kod": 0, "proverok": 26, "provalov": 0},
|
||
{"imya": "proverka-tekstovogo-sleda", "kod": 0, "proverok": 46, "provalov": 0}
|
||
]
|
||
}
|
||
JSON;
|
||
|
||
postuchat($telo)->assertStatus(202);
|
||
|
||
$o = otmetka();
|
||
expect($o)->not->toBeNull()
|
||
->and($o->last_error)->toBeNull()
|
||
->and(Carbon::parse($o->last_run_at)->toIso8601String())
|
||
->toStartWith('2026-08-06T18:42:52');
|
||
|
||
// И тот же настоящий итог, состарившись, обязан поднять тревогу.
|
||
DB::table('scheduler_heartbeats')
|
||
->where('command_name', 'lena:obhod-storozhey')
|
||
->update(['last_run_at' => now()->subHours(50)]);
|
||
|
||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||
Mail::assertSent(LenaStorozhaTrevogaMail::class);
|
||
});
|
||
|
||
// ====================================================== ЕЖЕДНЕВНАЯ ПРОВЕРКА ==
|
||
|
||
it('при здоровье письма НЕ шлёт', function (): void {
|
||
polozhitOtmetku(['last_run_at' => now()->subHours(2), 'last_success_at' => now()->subHours(2)]);
|
||
|
||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||
|
||
Mail::assertNothingSent();
|
||
});
|
||
|
||
it('🔴 шлёт письмо на ПРОТУХШИЙ итог, даже если внутри всё зелено', function (): void {
|
||
// Тот самый нож: допущение «итог пришёл — значит машина жива». Итог
|
||
// пришёл ВЧЕРА и был зелёным, сегодня машина молчит. Тревога обязана быть
|
||
// от ВОЗРАСТА, а не от содержания.
|
||
polozhitOtmetku([
|
||
'last_run_at' => now()->subHours(40),
|
||
'last_success_at' => now()->subHours(40),
|
||
'last_error' => null,
|
||
]);
|
||
|
||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||
|
||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||
return str_contains($m->prichina, 'ПРОТУХ') && $m->vozrastChasov >= 40;
|
||
});
|
||
});
|
||
|
||
it('🔴 шлёт письмо на красного сторожа и называет его', function (): void {
|
||
polozhitOtmetku([
|
||
'last_run_at' => now()->subHours(1),
|
||
'last_error' => 'proverka-raspisaniya: код 1, проверок 26, красных 3',
|
||
'consecutive_failures' => 1,
|
||
]);
|
||
|
||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||
|
||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||
return str_contains($m->prichina, 'ПОКРАСНЕЛ')
|
||
&& str_contains($m->podrobno, 'proverka-raspisaniya');
|
||
});
|
||
});
|
||
|
||
it('шлёт письмо, когда итога не было ни разу', function (): void {
|
||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||
|
||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||
return str_contains($m->prichina, 'НЕ БЫЛО НИ РАЗУ');
|
||
});
|
||
});
|
||
|
||
it('🔴 шлёт письмо на адрес ИЗ НАСТРОЙКИ, а не на вписанный в код', function (): void {
|
||
// Решение владельца Р117. Здешняя беда, которую не повторяем: соседний
|
||
// сторож пульса шлёт на личную почту, ЖЁСТКО вписанную в его код.
|
||
config()->set('services.monitoring.alert_email', 'ops-proba@liderra.ru');
|
||
polozhitOtmetku(['last_run_at' => now()->subHours(99)]);
|
||
|
||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||
|
||
Mail::assertSent(LenaStorozhaTrevogaMail::class, function (LenaStorozhaTrevogaMail $m) {
|
||
return $m->hasTo('ops-proba@liderra.ru');
|
||
});
|
||
expect(file_get_contents(app_path('Console/Commands/LenaProverkaObhodaStorozhey.php')))
|
||
->not->toContain('kdv1@bk.ru');
|
||
});
|
||
|
||
it('порог свежести берётся из настройки', function (): void {
|
||
config()->set('storozha_mashiny.svezhest_chasov', 100);
|
||
polozhitOtmetku(['last_run_at' => now()->subHours(40), 'last_success_at' => now()->subHours(40)]);
|
||
|
||
test()->artisan('lena:proverka-obhoda')->assertSuccessful();
|
||
|
||
Mail::assertNothingSent();
|
||
});
|
||
|
||
// ======================================================== ВТОРОЙ СЛОЙ ========
|
||
|
||
it('🔴 сама ежедневная проверка стоит под присмотром пульса', function (): void {
|
||
// Иначе завели бы молчуна вторым слоем: портал ждёт стука, а что живо
|
||
// само ожидание — не проверяет никто.
|
||
expect(SchedulerHeartbeatTracker::EXPECTED_INTERVALS)
|
||
->toHaveKey('lena:proverka-obhoda');
|
||
});
|
||
|
||
it('🔴 команда стоит в расписании портала — зовущий у неё есть', function (): void {
|
||
$raspisanie = file_get_contents(base_path('routes/console.php'));
|
||
expect($raspisanie)->toContain("Schedule::command('lena:proverka-obhoda')");
|
||
});
|