56ddaa1ed4
Решение владельца Р127. В `odinVidNomera` восьмёрка приводится к семёрке ТОЛЬКО когда цифр ровно одиннадцать. Короткие и длинные номера не трогаются вовсе: слить двух разных людей в один знак — ошибка тише и хуже, второму не позвонили бы никогда и молча. Замерено до правки: `8 999 000-00-01` и `+7 999 000-00-01` давали два разных знака и два разных тела. Сторожа смотрят на то, что вправду уехало роботу, и сравнивают дословно — так же, как сравнивает приёмник. Сторожа показаны красными тремя сломами: без починки, без проверки длины и с приведением к восьмёрке вместо семёрки. Возврат доказан слепком файла. Названная владельцу цена: иностранные одиннадцатизначные номера с кодом на восьмёрку это правило портит. Вьетнамские `8496…`/`8497…`/`8498…` станут похожи на живые коды Московской области — робот позвонит чужому человеку. Лечится чисткой номеров на входе, это отдельная задача. Подробности и незакрытые хвосты — в отчёте. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
975 lines
54 KiB
PHP
975 lines
54 KiB
PHP
<?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'");
|
||
});
|