Files
portal/app/tests/Feature/Obzvon/ObzvonClientShovTest.php
T
Дмитрий 56ddaa1ed4 fix обзвон: один человек, записанный через 8 и через +7, получал два звонка
Решение владельца Р127. В `odinVidNomera` восьмёрка приводится к семёрке
ТОЛЬКО когда цифр ровно одиннадцать. Короткие и длинные номера не трогаются
вовсе: слить двух разных людей в один знак — ошибка тише и хуже, второму не
позвонили бы никогда и молча.

Замерено до правки: `8 999 000-00-01` и `+7 999 000-00-01` давали два разных
знака и два разных тела. Сторожа смотрят на то, что вправду уехало роботу, и
сравнивают дословно — так же, как сравнивает приёмник.

Сторожа показаны красными тремя сломами: без починки, без проверки длины и с
приведением к восьмёрке вместо семёрки. Возврат доказан слепком файла.

Названная владельцу цена: иностранные одиннадцатизначные номера с кодом на
восьмёрку это правило портит. Вьетнамские `8496…`/`8497…`/`8498…` станут
похожи на живые коды Московской области — робот позвонит чужому человеку.
Лечится чисткой номеров на входе, это отдельная задача. Подробности и
незакрытые хвосты — в отчёте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:25:28 +03:00

975 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Services\Obzvon\IskhodObrashcheniya;
use App\Services\Obzvon\ObzvonClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Сторожа портальной половины шва «портал зовёт голосового робота» — задача З-0.3.
*
* 🔴 Чего эти сторожа НЕ доказывают, говорю сразу и вслух:
*
* 1. **Что робот и вправду узнаёт повтор по знаку задания.** Здесь доказано
* только то, что портал знак вырабатывает устойчиво, складывает его САМ (а
* значит рассогласовать его с номером нечем), шлёт одно и то же и в теле, и в
* двух заголовках. Замыкает защиту приёмник З-0.6, и он не здесь.
* 2. **Что предел времени и вправду обрывает медленное чтение.** Подставной робот
* подменяет собой весь перевозчик Guzzle — настоящие пределы времени при
* подставном роботе не срабатывают в принципе. Здесь доказано поведение НА
* обрыве и то, что оба предела заданы и не нулевые.
* 3. **Что предел одновременных держит нагрузку на живой машине.** В тестах кэш
* `array` — счёт мест идёт внутри одного процесса. Доказано, что счёт ведётся
* ЗАМКАМИ ОБЩЕГО КЭША (чужой работник имитируется взятием того же замка
* снаружи), а не числом в памяти клиента. На бою (`file`) те же замки — общие.
*/
beforeEach(function () {
// Ни один сторож не имеет права уйти в настоящую сеть.
Http::preventStrayRequests();
config()->set('services.obzvon.allowed_hosts', ['robot.test', '127.0.0.1', 'localhost']);
});
/** Клиент с разумными умолчаниями; любой довод перебивается на месте. */
function klientObzvona(array $peremeny = []): ObzvonClient
{
return new ObzvonClient(
endpoint: $peremeny['endpoint'] ?? 'https://robot.test/obzvon',
key: $peremeny['key'] ?? 'tainyy-klyuch-robota',
concurrency: $peremeny['concurrency'] ?? 4,
waitSeconds: $peremeny['waitSeconds'] ?? 0,
connectTimeout: $peremeny['connectTimeout'] ?? 5,
timeout: $peremeny['timeout'] ?? 15,
razreshyonnyeHosty: $peremeny['razreshyonnyeHosty'] ?? ['robot.test', '127.0.0.1', 'localhost'],
);
}
/**
* Одно пробное задание — ровно то, ИЗ ЧЕГО клиент сам сложит знак.
*
* 🔴 Знака среди доводов нет и быть не может: `pozvonit` его не принимает.
* Раньше сторожа звали `pozvonit(znakZadaniya(), '+79991234567')` — то есть
* знак был посчитан от одного написания номера, а в тело ехало другое. Ровно
* та рассогласованная пара, ради которой всё это переделано, жила прямо здесь.
*/
function zadanieProbnoe(array $peremeny = []): array
{
return array_merge([
'tenantId' => 1,
'campaignId' => 7,
'telefon' => '+7 999 123-45-67',
'popytkaNo' => 1,
], $peremeny);
}
/** Тот знак, какой клиент обязан сложить сам для {@see zadanieProbnoe()}. */
function znakZadaniya(): string
{
return ObzvonClient::klyuchZadaniya(1, 7, '+7 999 123-45-67', 1);
}
/**
* Тела ВСЕХ заданий, ушедших роботу на ручку `pozvonit`, по порядку.
*
* 🪤 Сторожа ниже смотрят на то, что и вправду уехало в сеть, а не на текст
* исходника: два помощника этой смены уже платили за сторожа, находившего своё
* же пояснение в файле и зеленевшего при сломанном коде.
*/
function telaUshedshih(): array
{
return Http::recorded(fn (Request $z) => str_ends_with($z->url(), '/pozvonit'))
->map(fn (array $para) => $para[0]->data())
->all();
}
// ─────────────────────────────────────────────────────────────────────────────
// Проверка 1 плана: робот молчит — портал не падает
// ─────────────────────────────────────────────────────────────────────────────
it('робот молчит — портал не падает, а говорит «не знаю»', function () {
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->poyasnenie)->toContain('робот молчит');
});
it('🔴 молчание робота НЕ значит «звонка не было» — повторять вслепую нельзя', function () {
Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect'));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
// Ровно тот случай, ради которого исходов пять: робот мог принять задание и
// уже звонить, а ответ потерялся по дороге. Скажи клиент «не вышло» — живому
// человеку позвонили бы второй раз.
expect($otvet->zvonokMogSostoyatsya())->toBeTrue();
});
it('🔴 клиент НЕ повторяет запрос сам — на молчание уходит ровно одно обращение', function () {
// SelfRenderClient повторяет до двух раз, и там это правильно. Здесь повтор —
// второй звонок живому человеку и вторые деньги.
// 🪤 Http::assertSentCount здесь негоден: оборванный запрос Laravel в список
// отправленных НЕ кладёт — счётчик показал бы ноль при любом числе повторов.
// Считаем сами, прямо в подставном роботе.
$skolko = 0;
Http::fake(function () use (&$skolko) {
$skolko++;
throw new ConnectionException('оборвано');
});
klientObzvona()->pozvonit(...zadanieProbnoe());
expect($skolko)->toBe(1);
});
it('о молчании робота пишется строка уровня «предупреждение», а не ниже', function () {
$zhurnal = Log::spy();
Http::fake(fn () => throw new ConnectionException('оборвано'));
klientObzvona()->pozvonit(...zadanieProbnoe());
// 🔴 На бою LOG_LEVEL=warning: всё ниже предупреждения не доезжает НИКОГДА
// (оплачено 04.08.2026 — починка выглядела сделанной и была мёртвой).
$zhurnal->shouldHaveReceived('warning');
$zhurnal->shouldNotHaveReceived('info');
$zhurnal->shouldNotHaveReceived('notice');
$zhurnal->shouldNotHaveReceived('debug');
});
// ─────────────────────────────────────────────────────────────────────────────
// Проверка 2 плана: предел времени
// ─────────────────────────────────────────────────────────────────────────────
it('выход за предел времени — «не знаю», работник очереди не держится дальше', function () {
// Именно так выглядит выход за предел времени в Guzzle: ConnectionException.
Http::fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out after 15001 ms'));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->poyasnenie)->toContain('timed out');
});
it('🔴 пределов времени ДВА, и оба не нулевые', function () {
// Сторож против тихого удаления второго предела. connect_timeout ловит только
// «не дозвонились до машины»; «робот отдаёт ответ по байту бесконечно» ловит
// ТОЛЬКО общий timeout — соединение в этом случае установлено.
$nastroyki = require __DIR__.'/../../../config/services.php';
expect($nastroyki['obzvon'])->toHaveKey('connect_timeout')
->and($nastroyki['obzvon'])->toHaveKey('timeout')
->and($nastroyki['obzvon']['connect_timeout'])->toBeGreaterThan(0)
->and($nastroyki['obzvon']['timeout'])->toBeGreaterThan(0);
});
// ─────────────────────────────────────────────────────────────────────────────
// Проверка 3 плана: пустой адрес или ключ — дверь закрыта
// ─────────────────────────────────────────────────────────────────────────────
it('пустой адрес — наружу не ходим, звонка точно не было', function () {
Http::fake();
$otvet = klientObzvona(['endpoint' => ''])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
});
it('пустой ключ — наружу не ходим', function () {
Http::fake();
$otvet = klientObzvona(['key' => ''])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->poyasnenie)->toContain('тайный ключ');
Http::assertNothingSent();
});
it('закрытая дверь не сыплет исключениями и не притворяется успехом', function () {
Http::fake();
$klient = klientObzvona(['endpoint' => '']);
expect($klient->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeFalse()
->and($klient->sostoyanie('c-1')->poluchenLi())->toBeFalse()
->and($klient->ostanovit('c-1')->poluchenLi())->toBeFalse();
});
// ─────────────────────────────────────────────────────────────────────────────
// Условленное слово — урок self_render
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 ответ 200 и мусором — это НЕ успех', function () {
// Тот самый урок: «ответ меньше 500 = жив» физически не мог покраснеть.
Http::fake(fn () => Http::response(['ерунда' => 'какая-то'], 200));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->poluchenLi())->toBeFalse();
});
it('🔴 ответ 200 и мусором — это и НЕ отказ, а «не знаю»', function () {
// Тело мог покалечить посредник уже ПОСЛЕ того, как робот принял задание.
// Записать мусор в «отказ» значит вернуть дыру с двойным звонком.
Http::fake(fn () => Http::response('совсем не json', 200));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
});
it('робот сказал «нет» — это отказ, и звонка точно не было', function () {
Http::fake(fn () => Http::response(['ok' => false, 'reason' => 'кампания не найдена'], 200));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
->and($otvet->poyasnenie)->toContain('кампания не найдена');
});
it('робот сломался 5xx — «не знаю», а не «не было»', function () {
Http::fake(fn () => Http::response(['ok' => false], 500));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno)
->and($otvet->zvonokMogSostoyatsya())->toBeTrue();
});
it('робот ответил 4xx — понял и отказался, звонка не было', function () {
Http::fake(fn () => Http::response(['ok' => false], 422));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
});
it('робот сказал условленное слово — задание принято, внешний номер запомнен', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-42'], 200));
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
->and($otvet->vneshniyIdZvonka)->toBe('rob-42');
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 Опознавательный знак задания — защита от двойного звонка
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 знак задания ОДИНАКОВ у повтора той же попытки', function () {
// Без этого повтор после потерянного ответа робот не отличит от нового
// задания и наберёт номер второй раз.
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1))
->toBe(ObzvonClient::klyuchZadaniya(1, 7, '+7 (999) 123-45-67', 1));
});
it('🔴 знак задания РАЗНЫЙ у разной попытки, номера, кампании и арендатора', function () {
// Вторая попытка дозвона — законный второй звонок, глушить его нельзя.
$osnova = ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1);
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 2))->not->toBe($osnova)
->and(ObzvonClient::klyuchZadaniya(1, 7, '+79990000000', 1))->not->toBe($osnova)
->and(ObzvonClient::klyuchZadaniya(1, 8, '+79991234567', 1))->not->toBe($osnova)
->and(ObzvonClient::klyuchZadaniya(2, 7, '+79991234567', 1))->not->toBe($osnova);
});
it('🔴 в знаке задания нет телефона человека — он необратим', function () {
expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1))->not->toContain('9991234567');
});
it('знак задания уходит роботу двумя заголовками, а ключ — своим', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$znak = znakZadaniya();
klientObzvona()->pozvonit(...zadanieProbnoe());
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $znak)
&& $z->hasHeader('Idempotency-Key', $znak)
&& $z->hasHeader('X-Obzvon-Key', 'tainyy-klyuch-robota'));
});
it('задание без номера — не звоним', function () {
Http::fake();
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => ' ']));
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 номер без единой цифры — не звоним, и это ошибка в журнале', function () {
// 🪤 Раньше сюда проваливалось всё, где нет цифр: `trim` такую строку не
// ловил, а знак от неё получался ОДИН И ТОТ ЖЕ у любых таких заданий —
// цифр ноль, хешу нечего различать. Два разных задания столкнулись бы
// знаками у робота, и одному из людей не позвонили бы вовсе.
$zhurnal = Log::spy();
Http::fake();
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => 'абв']));
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
$zhurnal->shouldHaveReceived('error');
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴🔴 ЗНАК И ТЕЛО ЗАДАНИЯ БОЛЬШЕ НЕ МОГУТ РАЗОЙТИСЬ
//
// Что было замерено до починки: знак считался от ОДНИХ ЦИФР телефона, а в тело
// задания клался номер КАК ЕСТЬ. Один и тот же человек, записанный
// `+7 999 000-00-01` и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник
// робота (З-0.6) снимает отпечаток с тела дословно и на такую пару отвечает 422
// — честный повтор получал отказ вместо звонка.
//
// И вторая половина той же беды: знак приходил ОТДЕЛЬНЫМ доводом от телефона.
// Работник очереди, посчитавший знак один раз и перебирающий номера, отправил
// бы роботу рассогласованную пару, и человеку не позвонили бы МОЛЧА.
//
// 🪤 Сторожа ниже смотрят на то, что ушло в сеть подставному роботу, — на
// работу, а не на слова в исходнике.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 А (контроль): одно задание дважды подряд — знак совпал и тело совпало', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe());
$klient->pozvonit(...zadanieProbnoe());
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
});
it('🔴 Б: тот же человек, записанный двумя видами — и знак, и ТЕЛО совпадают', function () {
// 🔴 Тот самый нож. До починки совпадал только знак: тело ехало «как есть»,
// и робот видел два разных задания под одним знаком → 422 честному повтору.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
$klient->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
});
it('🔴 В: рассогласовать знак и телефон нечем — знак снаружи не задаётся вовсе', function () {
// Проверяем не текст файла, а САМ ДОВОДНЫЙ ряд метода: если кто-нибудь
// вернёт знак отдельным доводом, сторож покраснеет.
$dovody = array_map(
fn (ReflectionParameter $p) => $p->getName(),
(new ReflectionMethod(ObzvonClient::class, 'pozvonit'))->getParameters(),
);
expect($dovody)->toBe(['tenantId', 'campaignId', 'telefon', 'popytkaNo', 'dopolnitelno']);
});
it('🔴 В: чем клиента ни зови — знак в теле всегда сходится с телефоном в теле', function () {
// Главный сторож починки. Он проверяет САМО СВОЙСТВО, ради которого всё
// сделано: для КАЖДОГО ушедшего задания знак обязан быть тем самым, какой
// считается от номера, уехавшего в этом же теле. Разойдись они хоть на один
// знак — робот ответит 422, и человеку не позвонят.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$vsyakie = [
['telefon' => '+7 999 000-00-01'],
['telefon' => '79990000001'],
['telefon' => ' 8 (999) 000 00 01 '],
['tenantId' => 42, 'telefon' => '+7-999-000-00-01'],
['campaignId' => 99, 'telefon' => '+7 999 000 00 01'],
['popytkaNo' => 3, 'telefon' => '+7 999 000-00-01'],
];
$klient = klientObzvona();
foreach ($vsyakie as $peremeny) {
$klient->pozvonit(...zadanieProbnoe($peremeny));
}
$tela = telaUshedshih();
expect($tela)->toHaveCount(count($vsyakie));
foreach ($tela as $nomer => $telo) {
$zadanie = zadanieProbnoe($vsyakie[$nomer]);
// 🪤 Первая половина обязательна отдельно, и вот почему. `klyuchZadaniya`
// сама приводит номер к одному виду — значит одной этой сверки МАЛО:
// положи клиент в тело номер с плюсами и скобками, знак всё равно сошёлся
// бы, а робот сравнивает тело ДОСЛОВНО и ответил бы 422. Замерено: без
// этой строки сторож оставался зелёным при возвращённой беде.
expect($telo['telefon'])->toMatch('/^\d+$/');
expect($telo['zadanie'])->toBe(ObzvonClient::klyuchZadaniya(
$zadanie['tenantId'],
$zadanie['campaignId'],
$telo['telefon'], // 🔴 именно тот номер, что уехал роботу
$zadanie['popytkaNo'],
));
}
});
it('🔴 Г: вторая попытка по тому же номеру — знак ДРУГОЙ, звонок законный', function () {
// Вторая попытка дозвона — законный второй звонок, глушить его нельзя.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 1]));
$otvet = $klient->pozvonit(...zadanieProbnoe(['popytkaNo' => 2]));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->not->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
->and($otvet->poluchenLi())->toBeTrue();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴🔴 Р127 — ВОСЬМЁРКА К СЕМЁРКЕ, И ТОЛЬКО ПРИ ОДИННАДЦАТИ ЦИФРАХ
//
// Что было замерено ДО починки: `8 999 000-00-01` и `+7 999 000-00-01` — один и
// тот же живой человек, записанный двумя обычными способами, — давали ДВА разных
// знака и два разных тела. Ему позвонили бы дважды, и клиент заплатил бы за два
// звонка вместо одного.
//
// Решение владельца Р127: приводить `8` к `7` ТОЛЬКО когда цифр ровно
// одиннадцать. Короткие и длинные не трогать вовсе — там `8` может значить что
// угодно, и слияние двух РАЗНЫХ людей в один знак было бы ошибкой в обратную
// сторону: второму не позвонили бы никогда и МОЛЧА.
//
// 🪤 Сторожа ниже сверяют то, что и вправду уехало подставному роботу, и
// сверяют ДОСЛОВНО — так же, как сравнивает приёмник. Сверка «с точностью до
// приведения» была бы слепа ровно к той половине беды, ради которой пишется.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 Р127 А (нож): восьмёрка и плюс-семь — ОДИН человек, один знак и одно тело', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01']));
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
->and($tela[0]['telefon'])->toBe('79990000001');
});
it('🔴 Р127 Б (контроль): что совпадало до починки — совпадает и после', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '+7 (999) 000-00-01']));
$klient->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon']);
});
it('🔴 Р127 В (граница): одиннадцать цифр НЕ с восьмёрки — не трогаем', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '79990000001']));
expect(telaUshedshih()[0]['telefon'])->toBe('79990000001');
});
it('🔴 Р127 Г (граница): начинается на 8, но цифр НЕ одиннадцать — не трогаем вовсе', function () {
// 🔴 Ровно то, что владелец отрезал сознательно. Короткий `8495123` — это не
// «российский мобильный без кода»: подставь мы туда семёрку, два РАЗНЫХ
// человека могли бы слиться в один знак, и второму не позвонили бы МОЛЧА.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8495123']));
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8999000000012']));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[0]['telefon'])->toBe('8495123')
->and($tela[1]['telefon'])->toBe('8999000000012');
});
it('🔴 Р127 Г: длинный номер с восьмёрки НЕ сливается со своим семёрочным двойником', function () {
// Сторож против «улучшения» правила: сними кто-нибудь проверку длины — эти
// два знака стали бы одним, и одному из двух людей не позвонили бы вовсе.
expect(ObzvonClient::klyuchZadaniya(1, 7, '8999000000012', 1))
->not->toBe(ObzvonClient::klyuchZadaniya(1, 7, '7999000000012', 1));
expect(ObzvonClient::klyuchZadaniya(1, 7, '8495123', 1))
->not->toBe(ObzvonClient::klyuchZadaniya(1, 7, '7495123', 1));
});
it('🔴 Р127 Д (граница): строка без цифр — дверь по-прежнему закрыта', function () {
// Это уже было построено до Р127 и обязано пережить починку: восьмёрка не
// должна была открыть дверь тому, кому она закрыта.
Http::fake();
$otvet = klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => 'абв']));
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
});
it('🔴 Р127 Е: вторая попытка по восьмёрочному номеру — знак ДРУГОЙ, звонок законный', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01', 'popytkaNo' => 1]));
$otvet = $klient->pozvonit(...zadanieProbnoe(['telefon' => '8 999 000-00-01', 'popytkaNo' => 2]));
$tela = telaUshedshih();
expect($tela)->toHaveCount(2)
->and($tela[1]['zadanie'])->not->toBe($tela[0]['zadanie'])
->and($tela[1]['telefon'])->toBe($tela[0]['telefon'])
->and($otvet->poluchenLi())->toBeTrue();
});
it('🔴 Р127: приведение НЕ портит знак дважды — второй прогон ничего не меняет', function () {
// 🪤 `pozvonit` приводит номер, а `klyuchZadaniya` приводит его ВТОРОЙ раз
// уже приведённым. Не будь правило устойчивым к повтору, знак в теле и знак,
// посчитанный от тела, разошлись бы — и робот ответил бы 422.
expect(ObzvonClient::klyuchZadaniya(1, 7, '8 999 000-00-01', 1))
->toBe(ObzvonClient::klyuchZadaniya(1, 7, '79990000001', 1));
});
it('🔴 знак в заголовках — тот же самый, что в теле', function () {
// Приёмник сверяет тело с заголовком и на расхождении отвечает 400. Разойдись
// они здесь — не дозвонились бы вовсе, и по коду 400 причину не узнать.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 999 000-00-01']));
$telo = telaUshedshih()[0];
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $telo['zadanie'])
&& $z->hasHeader('Idempotency-Key', $telo['zadanie']));
});
it('🔴 в теле задания номер без украшений — того же вида, из какого посчитан знак', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe(['telefon' => '+7 (999) 000-00-01']));
expect(telaUshedshih()[0]['telefon'])->toBe('79990000001');
});
it('🔴 «что ещё сказать роботу» не может перебить знак или номер', function () {
// Отпечаток тела у робота снимается со ВСЕГО тела. Дай мы `dopolnitelno`
// перебить `zadanie` или `telefon` — рассогласование вернулось бы с другого
// конца, уже никем не охраняемое.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona()->pozvonit(...zadanieProbnoe([
'dopolnitelno' => ['zadanie' => 'z-chuzhoy', 'telefon' => '+70000000000', 'skript' => 'privet'],
]));
$telo = telaUshedshih()[0];
expect($telo['zadanie'])->toBe(znakZadaniya())
->and($telo['telefon'])->toBe('79991234567')
->and($telo['skript'])->toBe('privet');
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 Адрес с опечаткой уводит тайный ключ чужому
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 адрес ведёт на неразрешённый хост — ключ чужому НЕ уходит', function () {
Http::fake();
$otvet = klientObzvona(['endpoint' => 'https://rrobot.test/obzvon'])
->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->poyasnenie)->toContain('неразрешённый хост');
Http::assertNothingSent();
});
it('🔴 пустой список разрешённых хостов закрывает всё, а не открывает', function () {
Http::fake();
$otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('адрес без хоста вовсе — дверь закрыта', function () {
Http::fake();
$otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 Р123: к роботу только по шифрованной дороге, кроме своей машины
//
// Замерено прибором приёмки 07.08.2026: разрешённый хост + `http://` уходил
// наружу молча, и тайный ключ ехал в заголовке ОТКРЫТЫМ ТЕКСТОМ. Одна опечатка
// в `OBZVON_ENDPOINT` — и ключ у чужого человека, без единого признака.
//
// 🪤 Каждый случай меряется ОТДЕЛЬНО, своим подставным роботом: на прошлой
// приёмке подставной робот протёк из случая в случай и два замера были ложью.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 ж: разрешённый хост, но http без шифра — ключ наружу НЕ уходит', function () {
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse()
->and($otvet->poyasnenie)->toContain('без шифрования');
Http::assertNothingSent();
});
it('🔴 з: тот же хост по https — работает и после запрета', function () {
// Контроль. Без него запрет мог бы закрыть дверь вообще всем и выглядеть
// «зелёным»: живой робот на 51.250.1.97 слушает 443 уже сегодня.
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-7'], 200));
$otvet = klientObzvona([
'endpoint' => 'https://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen)
->and($otvet->vneshniyIdZvonka)->toBe('rob-7');
});
it('🔴 и: свой компьютер, http на localhost — работает, разработка не встаёт', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
$otvet = klientObzvona([
'endpoint' => 'http://localhost:8080/lena',
'razreshyonnyeHosty' => ['localhost'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
});
it('🔴 и: свой компьютер, http на 127.0.0.1 — работает', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'mestnyy'], 200));
$otvet = klientObzvona([
'endpoint' => 'http://127.0.0.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.0.1'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen);
});
it('🔴 к: тот же местный адрес НА БОЕВОМ — дверь закрыта', function () {
app()->detectEnvironment(fn () => 'production');
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://127.0.0.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.0.1'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
});
// ─────────────────────────────────────────────────────────────────────────────
// 🔴 Сторож на само исключение: «своя машина» — ДВА независимых ответа
//
// Владелец назвал незакрытым: «ошибись кто-нибудь настройкой на боевом так,
// чтобы портал счёл себя своей машиной, — запрет отвалится, и сторожа на это
// нет». Сторож — здесь. Чтобы ключ уехал открытым, обязаны сломаться ОБА
// ответа сразу: и «где я живу», и «робот стоит на этой же машине». Одной
// опечатки в APP_ENV на боевом для утечки НЕ хватает.
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 APP_ENV=local на боевой машине НЕ открывает http к удалённому роботу', function () {
// Ровно тот случай: настройку окружения перепутали, портал считает себя
// своей машиной. Робот при этом на чужом железе — ключ поехал бы в интернет.
app()->detectEnvironment(fn () => 'local');
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 «своя машина» — не список слов: похожее имя хоста своей машиной не считается', function () {
// `localhost.zloumyshlennik.ru` кончается на что угодно и начинается со
// слова «localhost». Проверкой «есть ли в строке localhost» он бы прошёл.
app()->detectEnvironment(fn () => 'local');
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://localhost.zloumyshlennik.ru/lena',
'razreshyonnyeHosty' => ['localhost.zloumyshlennik.ru'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 вся петля 127.0.0.0/8 — своя машина, а соседний 127 в другом месте адреса — нет', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake(fn () => Http::response(['ok' => true], 200));
// 127.0.0.1 — не единственный адрес петли; сервер разработчика может жить
// на 127.0.0.53 (systemd-resolved) или 127.0.1.1 (Debian).
expect(klientObzvona([
'endpoint' => 'http://127.0.1.1:8080/lena',
'razreshyonnyeHosty' => ['127.0.1.1'],
])->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
});
it('🔴 адрес, похожий на петлю, но ею не являющийся — дверь закрыта', function () {
app()->detectEnvironment(fn () => 'local');
Http::fake();
// 127 внутри, но это чужая машина в интернете.
$otvet = klientObzvona([
'endpoint' => 'http://10.0.127.1:8080/lena',
'razreshyonnyeHosty' => ['10.0.127.1'],
])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🔴 запрет http на боевом виден в журнале, и уровень не ниже предупреждения', function () {
app()->detectEnvironment(fn () => 'production');
$zhurnal = Log::spy();
Http::fake();
$otvet = klientObzvona([
'endpoint' => 'http://51.250.1.97/lena',
'razreshyonnyeHosty' => ['51.250.1.97'],
])->pozvonit(...zadanieProbnoe());
// 🪤 Сперва — что дверь закрыта ИМЕННО из-за шифрования, а уж потом про
// журнал. Без первой строки сторож зеленел бы от чужого предупреждения в
// том же походе: сломай запрет — запрос уйдёт, робот ответит не тем, о чём
// договаривались, и строка `warning` появится сама, не о том.
expect($otvet->poyasnenie)->toContain('без шифрования');
Http::assertNothingSent();
// На бою LOG_LEVEL=warning: молчаливый отказ звонить был бы вторым сортом
// той же беды — обзвон встал бы, и никто бы не узнал почему.
$zhurnal->shouldHaveReceived('warning');
$zhurnal->shouldNotHaveReceived('info');
$zhurnal->shouldNotHaveReceived('notice');
$zhurnal->shouldNotHaveReceived('debug');
});
it('🔴 умолчание настроек не открывает http: в .env.example адрес робота шифрованный', function () {
// Сторож против тихого возврата дыры через образец настроек: скопировал
// .env.example — получил открытую дорогу.
$obrazec = (string) file_get_contents(__DIR__.'/../../../.env.example');
expect($obrazec)->not->toMatch('/^OBZVON_ENDPOINT=http:\/\//m');
});
it('🔴 проверка чужого удостоверения не отключена — иначе https был бы одной видимостью', function () {
// Та же беда с другого конца: `https://` в адресе, а проверку удостоверения
// машины кто-то снял, чтобы «заработало с самодельным сертификатом». Тогда
// шифрование есть, а подставиться на дороге может кто угодно — и ключ
// уедет ему. Laravel проверяет удостоверение по умолчанию; сторож стоит на
// том, чтобы эту проверку тут никогда не сняли.
$ishodnik = (string) file_get_contents(__DIR__.'/../../../app/Services/Obzvon/ObzvonClient.php');
expect($ishodnik)->not->toContain('withoutVerifying')
->and($ishodnik)->not->toContain("'verify' => false");
});
// ─────────────────────────────────────────────────────────────────────────────
// Проверка 4 плана: предел одновременных заданий
// ─────────────────────────────────────────────────────────────────────────────
it('🔴 все места заняты — исход «занято», наружу не ходили', function () {
Http::fake(fn () => Http::response(['ok' => true], 200));
// Чужие работники очереди — берём те же замки СНАРУЖИ клиента. Если бы предел
// жил числом в памяти клиента, этот сторож не покраснел бы никогда.
$chuzhie = [];
foreach ([0, 1] as $nomer) {
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
expect($zamok->get())->toBeTrue();
$chuzhie[] = $zamok;
}
$otvet = klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
->and($otvet->zvonokMogSostoyatsya())->toBeFalse();
Http::assertNothingSent();
foreach ($chuzhie as $zamok) {
$zamok->release();
}
});
it('освободилось место — задание проходит', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$zanyatye = [];
foreach ([0, 1] as $nomer) {
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
$zamok->get();
$zanyatye[] = $zamok;
}
$zanyatye[1]->release();
expect(klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe())->poluchenLi())
->toBeTrue();
$zanyatye[0]->release();
});
it('🔴 лишнее задание ЖДЁТ свободного места, а не отбивается сразу', function () {
Http::fake(fn () => Http::response(['ok' => true], 200));
$zamok = Cache::lock('obzvon:mesto:0', 60);
$zamok->get();
$nachalo = microtime(true);
$otvet = klientObzvona(['concurrency' => 1, 'waitSeconds' => 1])
->pozvonit(...zadanieProbnoe());
$proshlo = microtime(true) - $nachalo;
expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato)
->and($proshlo)->toBeGreaterThanOrEqual(1.0);
$zamok->release();
});
it('место возвращается после успешного обращения', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
foreach ([0, 1] as $nomer) {
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
expect($zamok->get())->toBeTrue();
$zamok->release();
}
});
it('🔴 место возвращается, даже когда робот молчит', function () {
// Иначе одна беда навсегда съедала бы место, и обзвон затухал бы по одному.
Http::fake(fn () => throw new ConnectionException('оборвано'));
klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe());
foreach ([0, 1] as $nomer) {
$zamok = Cache::lock('obzvon:mesto:'.$nomer, 60);
expect($zamok->get())->toBeTrue();
$zamok->release();
}
});
// ─────────────────────────────────────────────────────────────────────────────
// Три умения ходят по своим ручкам
// ─────────────────────────────────────────────────────────────────────────────
it('три умения зовут три разные ручки робота', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
$klient = klientObzvona();
$klient->pozvonit(...zadanieProbnoe());
$klient->sostoyanie('rob-42');
$klient->ostanovit('rob-42', 'человек попросил');
Http::assertSent(fn (Request $z) => $z->url() === 'https://robot.test/obzvon/pozvonit');
Http::assertSent(fn (Request $z) => $z->url() === 'https://robot.test/obzvon/sostoyanie');
Http::assertSent(fn (Request $z) => $z->url() === 'https://robot.test/obzvon/ostanovit');
});
it('состояние разговора возвращает то, что сказал робот', function () {
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-42', 'sostoyanie' => 'govorit'], 200));
$otvet = klientObzvona()->sostoyanie('rob-42');
expect($otvet->poluchenLi())->toBeTrue()
->and($otvet->telo['sostoyanie'])->toBe('govorit');
});
it('состояние и остановка без номера звонка — наружу не ходим', function () {
Http::fake();
$klient = klientObzvona();
expect($klient->sostoyanie('')->iskhod)->toBe(IskhodObrashcheniya::DverZakryta)
->and($klient->ostanovit(' ')->iskhod)->toBe(IskhodObrashcheniya::DverZakryta);
Http::assertNothingSent();
});
it('🪤 «остановлено» роботом не доказано, пока он молчит', function () {
Http::fake(fn () => throw new ConnectionException('оборвано'));
// Тишина ответа не равна тишине в трубке: разговор может идти дальше.
expect(klientObzvona()->ostanovit('rob-42')->iskhod)->toBe(IskhodObrashcheniya::Neizvestno);
});
// ─────────────────────────────────────────────────────────────────────────────
// Настройки
// ─────────────────────────────────────────────────────────────────────────────
it('клиент из настроек берёт адрес, ключ и пределы', function () {
config()->set('services.obzvon.endpoint', 'https://robot.test/obzvon');
config()->set('services.obzvon.key', 'iz-nastroek');
config()->set('services.obzvon.concurrency', 2);
config()->set('services.obzvon.wait_seconds', 0);
Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200));
expect(ObzvonClient::izNastroek()->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue();
Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Key', 'iz-nastroek'));
});
it('🔴 в настройках нет ни одного секрета в открытом виде', function () {
$ishodnik = (string) file_get_contents(__DIR__.'/../../../config/services.php');
$blok = mb_substr($ishodnik, (int) mb_strpos($ishodnik, "'obzvon' => ["));
// Всё, что секрет, обязано приходить только из окружения.
expect($blok)->toContain("env('OBZVON_KEY'")
->and($blok)->toContain("env('OBZVON_ENDPOINT'");
});