diff --git a/app/app/Services/Obzvon/ObzvonClient.php b/app/app/Services/Obzvon/ObzvonClient.php index 487b60c2..cad7cd0d 100644 --- a/app/app/Services/Obzvon/ObzvonClient.php +++ b/app/app/Services/Obzvon/ObzvonClient.php @@ -61,9 +61,16 @@ use Illuminate\Support\Facades\Log; * что это то же самое задание, а не новое. Знак даёт {@see self::klyuchZadaniya()}: * он ОДИНАКОВ у всех повторов одной и той же попытки и РАЗНЫЙ у разных попыток. * - * 🔴 Клиент не выдумывает знак сам и не подставляет случайный. Случайный знак - * выглядел бы как защита и не защищал бы ничего — при каждом повторе он был бы - * новым. Задание без знака = дверь закрыта + запись в журнал уровня «ошибка». + * 🔴 Знак не случайный и не выдуманный: он посчитан от арендатора, кампании, + * номера и числа попытки. Случайный выглядел бы как защита и не защищал бы + * ничего — при каждом повторе он был бы новым. + * + * 🔴 И знак не приходит СНАРУЖИ. {@see self::pozvonit()} принимает то, ИЗ ЧЕГО + * знак складывается, и считает его сам, а номер и для знака, и для тела берёт из + * одного места ({@see self::odinVidNomera()}). Пока знак и телефон были двумя + * независимыми доводами, ничто не заставляло их сойтись: рассогласованная пара + * доезжала до робота, робот отвечал отказом, и человеку не звонили МОЛЧА. + * Теперь рассогласовать нечего — второго номера в этом клиенте не существует. * * 🪤 Чего эта половина НЕ доказывает: что робот знак и вправду читает. Приёмник * (З-0.6) обязан хранить знаки и на повтор отвечать `{"ok":true, "call_id": <тот @@ -174,52 +181,96 @@ final class ObzvonClient * * 🪤 Телефон входит в знак только хешем: знак ложится в журналы и уедет к * роботу, а телефон — персональные данные человека. + * + * 🔴 Номер приводится к одному виду {@see self::odinVidNomera()} — тем же + * самым, каким он поедет роботу в теле задания. Это одно место, а не два. */ public static function klyuchZadaniya(int $tenantId, int $campaignId, string $telefon, int $popytkaNo): string { return 'z'.substr(hash('sha256', implode('|', [ $tenantId, $campaignId, - preg_replace('/\D+/', '', $telefon) ?? '', + self::odinVidNomera($telefon), $popytkaNo, ])), 0, 40); } + /** + * 🔴 ОДИН ВИД НОМЕРА — единственное место во всём клиенте, где номер + * приводится к канону, и оба потребителя берут его отсюда: и знак + * ({@see self::klyuchZadaniya()}), и тело задания ({@see self::pozvonit()}). + * + * Пока мест было два, один и тот же человек, записанный `+7 999 000-00-01` + * и `79990000001`, давал ОДИН знак и РАЗНЫЕ тела. Приёмник робота (З-0.6) + * снимает отпечаток с тела ДОСЛОВНО и на такую пару отвечает 422 — то есть + * честный повтор получал отказ вместо звонка. + * + * 🪤 Что этот канон НЕ делает, говорю вслух: он снимает ЗАПИСЬ (плюсы, + * скобки, пробелы, тире), но не решает, один ли человек за `8999…` и + * `+7999…`. Для портала это сегодня РАЗНЫЕ люди — разный знак, два звонка. + * Считать ли их одним, решает владелец, и это отдельное решение: ошибись оно + * в другую сторону — два РАЗНЫХ человека слились бы в один знак, и второму + * не позвонили бы никогда. Молча угадывать такое нельзя. + */ + private static function odinVidNomera(string $telefon): string + { + return preg_replace('/\D+/', '', $telefon) ?? ''; + } + /** * Попросить робота позвонить по номеру. * * 🔴 Один вызов = РОВНО ОДИН запрос к роботу. Сам не повторяет — см. шапку. * - * @param string $klyuchZadaniya знак от {@see self::klyuchZadaniya()}; пустой = не звоним + * 🔴 ЗНАК СЧИТАЕТСЯ ЗДЕСЬ, А НЕ ПРИНИМАЕТСЯ СНАРУЖИ. Раньше знак и телефон + * приходили двумя независимыми доводами, и ничто не заставляло их сойтись: + * работник очереди, посчитавший знак один раз и перебирающий номера, отправил + * бы роботу рассогласованную пару — робот ответил бы 422, а человеку не + * позвонили бы МОЛЧА. Теперь рассогласовать нечего: наружу принимается ровно + * то, ИЗ ЧЕГО знак складывается, и складывает его сам клиент. + * + * 🪤 Что отсюда следует для правила счёта попыток (решение владельца Р124). + * Робот помнит знаки сутки и различает попытки по `$popytkaNo`. Значит на + * ПОВТОРЕ после исхода {@see ObzvonOtvet::zvonokMogSostoyatsya()} = «да» + * (робот молчит, 5xx, ответ без условленного слова) номер попытки обязан + * остаться ТЕМ ЖЕ — иначе знак станет другим и защита не сработает именно + * в том случае, ради которого заведена. Прибавлять попытку можно только + * после исхода, где звонка ТОЧНО не было (дверь закрыта, занято, отказ), + * либо после состоявшегося и завершённого разговора. Сам клиент этого + * правила не знает и знать не должен — он считает знак от того, что ему + * сказали. Довод виден в каждом вызове нарочно: правило будет исполняться + * на глазах, а не прятаться внутри. + * * @param array $dopolnitelno что ещё сказать роботу (сценарий, номер «от кого») + * + * 🪤 `$dopolnitelno` тоже входит в отпечаток тела у робота. Значит оно + * обязано быть ОДИНАКОВЫМ у повтора той же попытки: время «сейчас», случайное + * число или поле, добавленное выкатом между попыткой и повтором, дадут + * честному повтору 422. Порядок ключей роли не играет — приёмник сортирует. */ - public function pozvonit(string $klyuchZadaniya, string $telefon, array $dopolnitelno = []): ObzvonOtvet + public function pozvonit(int $tenantId, int $campaignId, string $telefon, int $popytkaNo, array $dopolnitelno = []): ObzvonOtvet { - $klyuchZadaniya = trim($klyuchZadaniya); - $telefon = trim($telefon); + // 🔴 Один и тот же канон и для знака, и для тела: роботу уезжает ровно + // тот номер, из которого посчитан знак. Другого номера здесь просто нет. + $nomer = self::odinVidNomera($telefon); - if ($klyuchZadaniya === '') { - // Не «на всякий случай», а по существу: без знака робот не отличит - // повтор от нового задания, и первая же потеря ответа обернётся - // вторым звонком человеку. Молчать об этом нельзя — это ошибка кода. - Log::error('обзвон: задание без опознавательного знака — звонить отказано', [ - 'ruchka' => 'pozvonit', - ]); - - return ObzvonOtvet::dverZakryta('задание без опознавательного знака'); - } - - if ($telefon === '') { + if ($nomer === '') { + // Сюда попадает и пустая строка, и строка вовсе без цифр («абв»). + // Второе важно отдельно: раньше такие строки давали ОДИН знак на + // всех (цифр ноль — хешу нечего различать), и два разных задания + // столкнулись бы знаками у робота. Log::error('обзвон: задание без номера — звонить отказано', [ - 'zadanie' => $klyuchZadaniya, + 'ruchka' => 'pozvonit', ]); return ObzvonOtvet::dverZakryta('задание без номера'); } - return $this->obratitsya('pozvonit', $klyuchZadaniya, array_merge($dopolnitelno, [ - 'zadanie' => $klyuchZadaniya, - 'telefon' => $telefon, + $znak = self::klyuchZadaniya($tenantId, $campaignId, $nomer, $popytkaNo); + + return $this->obratitsya('pozvonit', $znak, array_merge($dopolnitelno, [ + 'zadanie' => $znak, + 'telefon' => $nomer, ])); } diff --git a/app/tests/Feature/Obzvon/ObzvonClientShovTest.php b/app/tests/Feature/Obzvon/ObzvonClientShovTest.php index 81b7efce..1208cbda 100644 --- a/app/tests/Feature/Obzvon/ObzvonClientShovTest.php +++ b/app/tests/Feature/Obzvon/ObzvonClientShovTest.php @@ -15,10 +15,10 @@ use Illuminate\Support\Facades\Log; * * 🔴 Чего эти сторожа НЕ доказывают, говорю сразу и вслух: * - * 1. **Что робот и вправду узнаёт повтор по знаку задания.** Приёмника на машине - * робота ещё нет вовсе (З-0.6). Здесь доказано только то, что портал знак - * вырабатывает устойчиво, шлёт его в двух заголовках и без знака звонить - * отказывается. Защита от двойного звонка ЗАЯВЛЕНА, но не замкнута. + * 1. **Что робот и вправду узнаёт повтор по знаку задания.** Здесь доказано + * только то, что портал знак вырабатывает устойчиво, складывает его САМ (а + * значит рассогласовать его с номером нечем), шлёт одно и то же и в теле, и в + * двух заголовках. Замыкает защиту приёмник З-0.6, и он не здесь. * 2. **Что предел времени и вправду обрывает медленное чтение.** Подставной робот * подменяет собой весь перевозчик Guzzle — настоящие пределы времени при * подставном роботе не срабатывают в принципе. Здесь доказано поведение НА @@ -49,11 +49,44 @@ function klientObzvona(array $peremeny = []): ObzvonClient ); } +/** + * Одно пробное задание — ровно то, ИЗ ЧЕГО клиент сам сложит знак. + * + * 🔴 Знака среди доводов нет и быть не может: `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 плана: робот молчит — портал не падает // ───────────────────────────────────────────────────────────────────────────── @@ -61,7 +94,7 @@ function znakZadaniya(): string it('робот молчит — портал не падает, а говорит «не знаю»', function () { Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect')); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno) ->and($otvet->poyasnenie)->toContain('робот молчит'); @@ -70,7 +103,7 @@ it('робот молчит — портал не падает, а говори it('🔴 молчание робота НЕ значит «звонка не было» — повторять вслепую нельзя', function () { Http::fake(fn () => throw new ConnectionException('cURL error 7: Failed to connect')); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); // Ровно тот случай, ради которого исходов пять: робот мог принять задание и // уже звонить, а ответ потерялся по дороге. Скажи клиент «не вышло» — живому @@ -90,7 +123,7 @@ it('🔴 клиент НЕ повторяет запрос сам — на мо throw new ConnectionException('оборвано'); }); - klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + klientObzvona()->pozvonit(...zadanieProbnoe()); expect($skolko)->toBe(1); }); @@ -99,7 +132,7 @@ it('о молчании робота пишется строка уровня « $zhurnal = Log::spy(); Http::fake(fn () => throw new ConnectionException('оборвано')); - klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + klientObzvona()->pozvonit(...zadanieProbnoe()); // 🔴 На бою LOG_LEVEL=warning: всё ниже предупреждения не доезжает НИКОГДА // (оплачено 04.08.2026 — починка выглядела сделанной и была мёртвой). @@ -117,7 +150,7 @@ it('выход за предел времени — «не знаю», рабо // Именно так выглядит выход за предел времени в Guzzle: ConnectionException. Http::fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out after 15001 ms')); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno) ->and($otvet->poyasnenie)->toContain('timed out'); @@ -142,7 +175,7 @@ it('🔴 пределов времени ДВА, и оба не нулевые', it('пустой адрес — наружу не ходим, звонка точно не было', function () { Http::fake(); - $otvet = klientObzvona(['endpoint' => ''])->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona(['endpoint' => ''])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta) ->and($otvet->zvonokMogSostoyatsya())->toBeFalse(); @@ -152,7 +185,7 @@ it('пустой адрес — наружу не ходим, звонка то it('пустой ключ — наружу не ходим', function () { Http::fake(); - $otvet = klientObzvona(['key' => ''])->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona(['key' => ''])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta) ->and($otvet->poyasnenie)->toContain('тайный ключ'); @@ -163,7 +196,7 @@ it('закрытая дверь не сыплет исключениями и н Http::fake(); $klient = klientObzvona(['endpoint' => '']); - expect($klient->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeFalse() + expect($klient->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeFalse() ->and($klient->sostoyanie('c-1')->poluchenLi())->toBeFalse() ->and($klient->ostanovit('c-1')->poluchenLi())->toBeFalse(); }); @@ -176,7 +209,7 @@ it('🔴 ответ 200 и мусором — это НЕ успех', function // Тот самый урок: «ответ меньше 500 = жив» физически не мог покраснеть. Http::fake(fn () => Http::response(['ерунда' => 'какая-то'], 200)); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->poluchenLi())->toBeFalse(); }); @@ -186,7 +219,7 @@ it('🔴 ответ 200 и мусором — это и НЕ отказ, а «н // Записать мусор в «отказ» значит вернуть дыру с двойным звонком. Http::fake(fn () => Http::response('совсем не json', 200)); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno) ->and($otvet->zvonokMogSostoyatsya())->toBeTrue(); @@ -195,7 +228,7 @@ it('🔴 ответ 200 и мусором — это и НЕ отказ, а «н it('робот сказал «нет» — это отказ, и звонка точно не было', function () { Http::fake(fn () => Http::response(['ok' => false, 'reason' => 'кампания не найдена'], 200)); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz) ->and($otvet->zvonokMogSostoyatsya())->toBeFalse() @@ -205,7 +238,7 @@ it('робот сказал «нет» — это отказ, и звонка т it('робот сломался 5xx — «не знаю», а не «не было»', function () { Http::fake(fn () => Http::response(['ok' => false], 500)); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Neizvestno) ->and($otvet->zvonokMogSostoyatsya())->toBeTrue(); @@ -214,7 +247,7 @@ it('робот сломался 5xx — «не знаю», а не «не был it('робот ответил 4xx — понял и отказался, звонка не было', function () { Http::fake(fn () => Http::response(['ok' => false], 422)); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Otkaz) ->and($otvet->zvonokMogSostoyatsya())->toBeFalse(); @@ -223,7 +256,7 @@ it('робот ответил 4xx — понял и отказался, звон it('робот сказал условленное слово — задание принято, внешний номер запомнен', function () { Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'rob-42'], 200)); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona()->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen) ->and($otvet->vneshniyIdZvonka)->toBe('rob-42'); @@ -254,22 +287,11 @@ it('🔴 в знаке задания нет телефона человека expect(ObzvonClient::klyuchZadaniya(1, 7, '+79991234567', 1))->not->toContain('9991234567'); }); -it('🔴 задание без знака — не звоним вовсе, и это ошибка в журнале', function () { - $zhurnal = Log::spy(); - Http::fake(); - - $otvet = klientObzvona()->pozvonit('', '+79991234567'); - - expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta); - Http::assertNothingSent(); - $zhurnal->shouldHaveReceived('error'); -}); - it('знак задания уходит роботу двумя заголовками, а ключ — своим', function () { Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200)); $znak = znakZadaniya(); - klientObzvona()->pozvonit($znak, '+79991234567'); + klientObzvona()->pozvonit(...zadanieProbnoe()); Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Zadanie', $znak) && $z->hasHeader('Idempotency-Key', $znak) @@ -279,12 +301,185 @@ it('знак задания уходит роботу двумя заголов it('задание без номера — не звоним', function () { Http::fake(); - $otvet = klientObzvona()->pozvonit(znakZadaniya(), ' '); + $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(); +}); + +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'); +}); + // ───────────────────────────────────────────────────────────────────────────── // 🔴 Адрес с опечаткой уводит тайный ключ чужому // ───────────────────────────────────────────────────────────────────────────── @@ -293,7 +488,7 @@ it('🔴 адрес ведёт на неразрешённый хост — кл Http::fake(); $otvet = klientObzvona(['endpoint' => 'https://rrobot.test/obzvon']) - ->pozvonit(znakZadaniya(), '+79991234567'); + ->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta) ->and($otvet->poyasnenie)->toContain('неразрешённый хост'); @@ -303,7 +498,7 @@ it('🔴 адрес ведёт на неразрешённый хост — кл it('🔴 пустой список разрешённых хостов закрывает всё, а не открывает', function () { Http::fake(); - $otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona(['razreshyonnyeHosty' => []])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta); Http::assertNothingSent(); @@ -312,7 +507,7 @@ it('🔴 пустой список разрешённых хостов закр it('адрес без хоста вовсе — дверь закрыта', function () { Http::fake(); - $otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona(['endpoint' => 'просто-строка'])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta); Http::assertNothingSent(); @@ -335,7 +530,7 @@ it('🔴 ж: разрешённый хост, но http без шифра — к $otvet = klientObzvona([ 'endpoint' => 'http://51.250.1.97/lena', 'razreshyonnyeHosty' => ['51.250.1.97'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta) ->and($otvet->zvonokMogSostoyatsya())->toBeFalse() @@ -351,7 +546,7 @@ it('🔴 з: тот же хост по https — работает и после $otvet = klientObzvona([ 'endpoint' => 'https://51.250.1.97/lena', 'razreshyonnyeHosty' => ['51.250.1.97'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen) ->and($otvet->vneshniyIdZvonka)->toBe('rob-7'); @@ -364,7 +559,7 @@ it('🔴 и: свой компьютер, http на localhost — работае $otvet = klientObzvona([ 'endpoint' => 'http://localhost:8080/lena', 'razreshyonnyeHosty' => ['localhost'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen); }); @@ -376,7 +571,7 @@ it('🔴 и: свой компьютер, http на 127.0.0.1 — работае $otvet = klientObzvona([ 'endpoint' => 'http://127.0.0.1:8080/lena', 'razreshyonnyeHosty' => ['127.0.0.1'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Poluchen); }); @@ -388,7 +583,7 @@ it('🔴 к: тот же местный адрес НА БОЕВОМ — две $otvet = klientObzvona([ 'endpoint' => 'http://127.0.0.1:8080/lena', 'razreshyonnyeHosty' => ['127.0.0.1'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta) ->and($otvet->zvonokMogSostoyatsya())->toBeFalse(); @@ -414,7 +609,7 @@ it('🔴 APP_ENV=local на боевой машине НЕ открывает ht $otvet = klientObzvona([ 'endpoint' => 'http://51.250.1.97/lena', 'razreshyonnyeHosty' => ['51.250.1.97'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta); Http::assertNothingSent(); @@ -429,7 +624,7 @@ it('🔴 «своя машина» — не список слов: похоже $otvet = klientObzvona([ 'endpoint' => 'http://localhost.zloumyshlennik.ru/lena', 'razreshyonnyeHosty' => ['localhost.zloumyshlennik.ru'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta); Http::assertNothingSent(); @@ -444,7 +639,7 @@ it('🔴 вся петля 127.0.0.0/8 — своя машина, а сосед expect(klientObzvona([ 'endpoint' => 'http://127.0.1.1:8080/lena', 'razreshyonnyeHosty' => ['127.0.1.1'], - ])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue(); + ])->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue(); }); it('🔴 адрес, похожий на петлю, но ею не являющийся — дверь закрыта', function () { @@ -455,7 +650,7 @@ it('🔴 адрес, похожий на петлю, но ею не являющ $otvet = klientObzvona([ 'endpoint' => 'http://10.0.127.1:8080/lena', 'razreshyonnyeHosty' => ['10.0.127.1'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::DverZakryta); Http::assertNothingSent(); @@ -469,7 +664,7 @@ it('🔴 запрет http на боевом виден в журнале, и у $otvet = klientObzvona([ 'endpoint' => 'http://51.250.1.97/lena', 'razreshyonnyeHosty' => ['51.250.1.97'], - ])->pozvonit(znakZadaniya(), '+79991234567'); + ])->pozvonit(...zadanieProbnoe()); // 🪤 Сперва — что дверь закрыта ИМЕННО из-за шифрования, а уж потом про // журнал. Без первой строки сторож зеленел бы от чужого предупреждения в @@ -522,7 +717,7 @@ it('🔴 все места заняты — исход «занято», нар $chuzhie[] = $zamok; } - $otvet = klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567'); + $otvet = klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe()); expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato) ->and($otvet->zvonokMogSostoyatsya())->toBeFalse(); @@ -545,7 +740,7 @@ it('освободилось место — задание проходит', fu $zanyatye[1]->release(); - expect(klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi()) + expect(klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe())->poluchenLi()) ->toBeTrue(); $zanyatye[0]->release(); @@ -559,7 +754,7 @@ it('🔴 лишнее задание ЖДЁТ свободного места, $nachalo = microtime(true); $otvet = klientObzvona(['concurrency' => 1, 'waitSeconds' => 1]) - ->pozvonit(znakZadaniya(), '+79991234567'); + ->pozvonit(...zadanieProbnoe()); $proshlo = microtime(true) - $nachalo; expect($otvet->iskhod)->toBe(IskhodObrashcheniya::Zanyato) @@ -571,7 +766,7 @@ it('🔴 лишнее задание ЖДЁТ свободного места, it('место возвращается после успешного обращения', function () { Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200)); - klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567'); + klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe()); foreach ([0, 1] as $nomer) { $zamok = Cache::lock('obzvon:mesto:'.$nomer, 60); @@ -584,7 +779,7 @@ it('🔴 место возвращается, даже когда робот м // Иначе одна беда навсегда съедала бы место, и обзвон затухал бы по одному. Http::fake(fn () => throw new ConnectionException('оборвано')); - klientObzvona(['concurrency' => 2])->pozvonit(znakZadaniya(), '+79991234567'); + klientObzvona(['concurrency' => 2])->pozvonit(...zadanieProbnoe()); foreach ([0, 1] as $nomer) { $zamok = Cache::lock('obzvon:mesto:'.$nomer, 60); @@ -601,7 +796,7 @@ it('три умения зовут три разные ручки робота', Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200)); $klient = klientObzvona(); - $klient->pozvonit(znakZadaniya(), '+79991234567'); + $klient->pozvonit(...zadanieProbnoe()); $klient->sostoyanie('rob-42'); $klient->ostanovit('rob-42', 'человек попросил'); @@ -647,7 +842,7 @@ it('клиент из настроек берёт адрес, ключ и пре Http::fake(fn () => Http::response(['ok' => true, 'call_id' => 'x'], 200)); - expect(ObzvonClient::izNastroek()->pozvonit(znakZadaniya(), '+79991234567')->poluchenLi())->toBeTrue(); + expect(ObzvonClient::izNastroek()->pozvonit(...zadanieProbnoe())->poluchenLi())->toBeTrue(); Http::assertSent(fn (Request $z) => $z->hasHeader('X-Obzvon-Key', 'iz-nastroek')); }); diff --git a/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-znak-tela-2026-08-07.md b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-znak-tela-2026-08-07.md new file mode 100644 index 00000000..d467d957 --- /dev/null +++ b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-znak-tela-2026-08-07.md @@ -0,0 +1,385 @@ +# Отчёт помощника — знак задания и тело задания больше не могут разойтись + +**Дата:** 07.08.2026 +**Ветка:** `feat/prospects-manual-testing-kp` +**Мои файлы:** `app/app/Services/Obzvon/ObzvonClient.php`, +`app/tests/Feature/Obzvon/ObzvonClientShovTest.php` + +--- + +## 0. Моя ошибка первой + +**Мой главный сторож был слеп ровно к той половине беды, ради которой я его +писал, и я узнал об этом только потому, что и вправду сломал починку.** + +Я написал сторожа «чем клиента ни зови — знак в теле всегда сходится с телефоном +в теле»: он берёт ушедшее роботу тело и проверяет, что +`zadanie === klyuchZadaniya(арендатор, кампания, telefon_из_тела, попытка)`. +Выглядело как главная проверка всей работы. Потом я вернул беду обратно (положил +в тело номер «как есть») — и **этот сторож остался ЗЕЛЁНЫМ**. Причина простая и +обидная: `klyuchZadaniya` сама приводит номер к одному виду, поэтому от +`+7 999 000-00-01` она даёт тот же знак, что и от `79990000001`. Сторож сверял +согласие «с точностью до нормализации», а робот сравнивает тело **дословно**. + +Починил: добавил в того же сторожа отдельную строку `expect($telo['telefon']) +->toMatch('/^\d+$/')` — тело обязано нести уже приведённый номер. После этого при +той же самой поломке сторож покраснел (замер — §3, поломка №1, было 3 красных, +стало 4). Если бы я не ломал починку руками, а поверил зелёному, я бы сдал работу +с дырявым главным сторожем. + +--- + +## 1. Разбор задания + +### 1.1. Картина беды владельца — подтверждаю обе половины, замерено + +1. **Знак и телефон приходили двумя независимыми доводами.** Так и было: + `pozvonit(string $klyuchZadaniya, string $telefon, ...)`. Ничто их не + связывало. +2. **Знак считался от одних цифр, а в тело клался номер как есть.** Так и было: + `klyuchZadaniya` — `preg_replace('/\D+/', '', $telefon)`, `pozvonit` — просто + `trim($telefon)`. + +Причём **рассогласованная пара жила прямо в сторожах**: почти все они звали +`pozvonit(znakZadaniya(), '+79991234567')`, где `znakZadaniya()` считался от +`'+7 999 123-45-67'`. То есть знак от одного написания, тело от другого. Сегодня +это никого не роняло только потому, что приёмника рядом нет. + +### 1.2. Что я проверил в приёмнике (читал, не правил) + +Читал `bots/lena-golos/priyomnik.py`. Важное для этой работы: + +- отпечаток тела снимается **вычитанием**: в него входит всё, кроме `zadanie` + (`POLYA_VNE_OTPECHATKA`) — значит и `telefon`, и всё `dopolnitelno`; +- **порядок ключей роли не играет** — приёмник сортирует + (`json.dumps(..., sort_keys=True)`). Значит порядок ключей на портале + выравнивать не надо, и я этого не делал; +- на расхождение тела — **422**, то есть 4xx, то есть для портала «звонка не + было». Приёмник сам говорит вслух: «правильное место починки — портал». + +Вывод: портал обязан гарантировать, что **у повтора той же попытки тело +побайтово то же**. Одного согласия «знак сходится» мало. + +### 1.3. 🔴 Второе место того же класса, о котором в задании не сказано + +**Строка вовсе без цифр давала один и тот же знак на все такие задания.** +`trim` ловил `' '`, но не ловил `'абв'`, `'---'` или `'+()'`: они проходили +дальше, а `klyuchZadaniya` от них получала **пустую строку цифр** — то есть знак +зависел только от арендатора, кампании и попытки. Два разных задания с +разными «мусорными» номерами **столкнулись бы знаками у робота**, и второму +человеку не позвонили бы вовсе (робот ответил бы «повтор» либо 422). + +Закрыл заодно: теперь отказ идёт по признаку «в номере ноль цифр», а не по +`trim`. Сторож — «🔴 номер без единой цифры — не звоним, и это ошибка в журнале». + +### 1.4. 🔴 Третье место того же класса — НЕ закрывал, потому что это не моё решение + +`8 999 000-00-01` и `+7 999 000-00-01` — почти наверняка **один и тот же +человек**, но цифры у них разные (`89990000001` против `79990000001`), значит +знак разный, значит **два звонка одному человеку**. Это ровно та беда, что в +задании, только с другого конца. + +Я это **не чиню намеренно** и говорю почему. Правило «какие записи номера значат +одного человека» — решение владельца, а не помощника: + +- ошибись оно в одну сторону — человеку позвонят дважды (сегодняшнее состояние); +- ошибись в другую — **два разных человека сольются в один знак**, и второму не + позвонят НИКОГДА. Второе хуже и тише. + +Кроме того, `8` — приставка не всемирная: слепое `8 → 7` сломает любой не-российский +номер. Написал вслух в §7 как открытый вопрос владельцу. + +### 1.5. 🔴 Четвёртое место — `dopolnitelno` входит в отпечаток тела + +`dopolnitelno` (сценарий, номер «от кого», имя собеседника) уезжает в том же +теле и **целиком входит в отпечаток робота**. Значит у повтора той же попытки +оно обязано быть **тем же**: время «сейчас», случайное число или поле, +добавленное выкатом между попыткой и повтором, дадут честному повтору 422. + +Чинить это в клиенте нечем — содержимое даёт зовущий. Что сделал: написал это +🪤-предупреждением прямо в докблоке `pozvonit`, чтобы будущий работник очереди +прочёл его в момент, когда пишет вызов. Порядок ключей выравнивать не надо — +приёмник сортирует (§1.2). + +### 1.6. Проверка владельца: «не сломает ли смена подписи `pozvonit`?» + +**Не ошиблись — боевых зовущих ноль.** Подробный замер поимённо — §6. + +--- + +## 2. Какой путь выбран и почему + +**Выбран путь первый: портал считает знак САМ внутри `pozvonit`, а наружу +принимает то, из чего знак складывается.** + +Новая подпись: + +```php +public function pozvonit( + int $tenantId, int $campaignId, string $telefon, int $popytkaNo, + array $dopolnitelno = [], +): ObzvonOtvet +``` + +Плюс появилось **одно** место приведения номера — `private static +odinVidNomera()`, и оба потребителя берут его оттуда: и `klyuchZadaniya()`, и +тело задания в `pozvonit()`. + +### 2.1. Решающий довод, которого в задании нет + +Второй путь — «знак остаётся доводом, но `pozvonit` проверяет, что он сходится» +— **всё равно требует сменить подпись**. Чтобы сверить знак, надо его +пересчитать, а для этого нужны арендатор, кампания и номер попытки. Их сегодня в +`pozvonit` нет. То есть второй путь платит **ту же самую цену** (смена подписи), +а взамен даёт: + +- **пять доводов вместо четырёх**, причём пятый — производная от остальных + четырёх, и единственное, чем он может отличаться, — быть неверным; +- **новый исход отказа** «знак не сходится → дверь закрыта». А чем кончается + «дверь закрыта»? Человеку не позвонили. То есть второй путь не убирает вред, а + делает его громким. Громкий лучше тихого, но «невозможно» лучше громкого. + +Раз цена одинаковая, а выигрыш только у первого — выбор не близкий. + +### 2.2. Что путь 1 стоит, говорю вслух + +- Клиент связи теперь знает доменные слова «арендатор», «кампания», «попытка». + Лёгкий запах слоёв. Но `klyuchZadaniya()` **уже живёт в этом же классе и уже + знает ровно эти четыре вещи** — новых знаний в класс не вошло, они лишь + перестали ходить вокруг него. +- Зовущий больше не может подставить произвольный знак. Если когда-нибудь + понадобится знак из внешнего источника — это будет отдельное решение владельца, + и оно упрётся в сторожа «В» (§3), а не проскочит молча. +- Зовущему по-прежнему есть чем записать знак в свою таблицу: + `ObzvonClient::klyuchZadaniya()` остался открытым, и он **обязан** совпасть с + отправленным — обе величины выходят из одной функции от одних доводов. + +### 2.3. 🔴 Р124 — что я об этом думаю (не переоткрываю, называю следствие) + +Владелец назвал следствие сам, и оно верное. Добавлю точную границу правила, +которое будет назначаться позже: + +> **Номер попытки нельзя прибавлять на исходе, где `zvonokMogSostoyatsya()` +> вернул «да».** Это «робот молчит», 5xx и «ответ без условленного слова ok». +> Прибавь портал попытку здесь — знак станет другим, робот не узнает повтор и +> наберёт номер второй раз, то есть защита не сработает **именно в том +> единственном случае, ради которого заведена**. +> +> Прибавлять попытку можно только после исхода, где звонка **точно** не было +> (дверь закрыта, занято, отказ), либо после состоявшегося и завершённого +> разговора, который решили повторить. + +Моя работа этого правила **не делает невозможным** — наоборот. Пока знак приходил +готовым доводом, правило пряталось внутри того, кто его считал. Теперь +`popytkaNo` — **отдельный видимый довод в каждом вызове**, и правило будет +исполняться на глазах, а не по памяти. Это записано 🪤-абзацем в докблоке +`pozvonit`, чтобы будущий работник очереди прочёл его в ту же секунду, когда +пишет вызов. + +Ещё одна граница Р124, которую стоит держать в уме: робот помнит знаки **сутки**. +Повтор той же попытки, дошедший позже суток (застрявшая джоба, ручной перезапуск +назавтра), робот уже не узнает и наберёт номер снова. Это окно робота, оно +названо в самом Р124, и портальной починкой не закрывается. + +--- + +## 3. Чем ронял сторожей и что они сказали + +Ронял **работой, а не текстом**: возвращал беду в код и смотрел на то, что +уходит подставному роботу. Слепков по тексту исходника я в новых сторожах не +делал вовсе — предупреждение из задания принято. + +### Поломка №1 — вернул в тело номер «как есть» + +`'telefon' => $nomer` → `'telefon' => trim($telefon)` (ровно прежнее поведение). + +Первый замер — **3 красных из 55**. Главный сторож остался зелёным, это моя +ошибка (§0). После усиления сторожа тем же ломом — **4 красных из 55**, дословно: + +``` +🔴 Б: тот же человек, записанный двумя видами — и знак, и ТЕЛО совпадают + Failed asserting that two strings are identical. + - '+7 999 000-00-01' + + '79990000001' + +🔴 В: чем клиента ни зови — знак в теле всегда сходится с телефоном в теле + Failed asserting that '+7 999 000-00-01' matches PCRE pattern "/^\d+$/". + +🔴 в теле задания номер без украшений — того же вида, из какого посчитан знак + Failed asserting that two strings are identical. + - '79990000001' + + '+7 (999) 000-00-01' + +🔴 «что ещё сказать роботу» не может перебить знак или номер + Failed asserting that two strings are identical. + - '79991234567' + + '+7 999 123-45-67' +``` + +### Поломка №2 — вернул знак доводом снаружи + +Добавил шестым доводом `string $klyuchZadaniya = ''` (то есть приоткрыл ту самую +дверь, через которую и приходила рассогласованная пара). **1 красный из 55**, +дословно: + +``` +🔴 В: рассогласовать знак и телефон нечем — знак снаружи не задаётся вовсе + Failed asserting that two arrays are identical. + @@ @@ + 2 => 'telefon', + 3 => 'popytkaNo', + 4 => 'dopolnitelno', + + 5 => 'klyuchZadaniya', + ] +``` + +Этот сторож читает **сам доводный ряд метода** через `ReflectionMethod`, а не +текст файла. Слово `klyuchZadaniya` в файле встречается многократно (в том числе +в моих же пояснениях) — текстовый сторож зеленел бы всегда. + +### Возврат — доказан слепком со снятием `\r` + +После возврата обеих поломок: + +``` +tr -d '\r' < app/app/Services/Obzvon/ObzvonClient.php | git hash-object --stdin + → cad7cd0d6288ff279159b9197f799a44e276a2d0 +tr -d '\r' < app/tests/Feature/Obzvon/ObzvonClientShovTest.php | git hash-object --stdin + → 1208cbda5ccb80eb2a9aa2f4bc59601194e39eb7 +``` + +Проба по своим сторожам после возврата: **55 проверок, 55 зелёных, 137 условий, +красных 0.** Ни одной строки поломок в файле не осталось — проверено поиском по +обеим внесённым приметам, найдено 0. + +### Прочие мерки по своим файлам + +- `vendor/bin/pint --test` по обоим файлам — **passed**. +- `composer stan` (Larastan, весь `app/`) — **0 ошибок**. + +--- + +## 4. Числа полного прогона + +**Своя база:** `liderra_testing_znak_telo` (создана, накатана `migrate --force`). +Ни одна из занятых баз не тронута. Прогон один, других проб на этой базе +параллельно не запускал. + +Команда: + +``` +DB_DATABASE=liderra_testing_znak_telo php -d memory_limit=-1 vendor/bin/pest +``` + +| Что | Число | +|---|---| +| Всего проверок | **5293** | +| Зелёных | **5289** | +| **Красных** | **0** | +| Ошибок | **0** | +| Пропущено | 4 | +| «Сомнительных» (risky) | 4 | +| Условий проверено | 21 798 | +| Время | 1 214 с (≈20 мин) | + +**Арифметика сходится:** 5289 + 0 + 0 + 4 = **5293** = всего. Считал не по коду +возврата, а по числам в самом выводе — красных, спрятавшихся в поле «ошибок», +нет: поле пустое. + +**Сверка с входным замером.** На входе владелец назвал 5242 проверки, красных 0. +Стало 5293 — **+51**. Из них **+8 мои** (в моём файле сторожей было 47 `it(`, +стало 55 — замерено `git show HEAD:… | grep -c`), остальные **+43** принесли +соседние смены, работающие в этой же ветке. + +**4 пропущенных и 4 «сомнительных» — не мои и не новые.** Ровно те же числа +лежат в выводах прогонов предыдущих смен этого дня (5207/5203/4/4 и +5242/5238/4/4). Мой прогон их не изменил. + +🔴 **Красноты в `Render`/`Autopodbor` не было** — сосед, правящий дверь боевого +рендера, ничего мне не уронил, и мне нечего было бы расследовать. + +--- + +## 5. Соседи + +моего имени нет ни в одном выводе ни одного сторожа + +чужие файлы в дереве не тронуты + +Правил ровно три файла, все три — мои по заданию: +`app/app/Services/Obzvon/ObzvonClient.php`, +`app/tests/Feature/Obzvon/ObzvonClientShovTest.php` и этот отчёт. Запрещённых +файлов не касался: ни `SelfRenderClient.php`, ни `config/services.php`, ни +`.env.example` (два моих сторожа их только ЧИТАЮТ — так было и до меня), ни +папки `bots/`, ни схемы, ни миграций. Правил только средствами правки +Edit/Write, ни одной строки через оболочку. + +--- + +## 6. Зовущие поимённо + +**Спросил у прибора, что он считает** — и это важно, потому что упоминаний и +вправду сильно больше, чем вызовов. + +Прибор: `rg` (через поиск по дереву), запрос `pozvonit|klyuchZadaniya|ObzvonClient`, +без `vendor/` и `node_modules/`. Он считает **упоминания в тексте**, а не вызовы: +в его выдаче лежат и докблоки, и комментарии, и `README`, и питон робота, где +`pozvonit` — это имя ручки HTTP, а не метод PHP. Поэтому глазами разбирал каждую +строку. + +### Зовущие `ObzvonClient::pozvonit()` — вызовы, поимённо + +| Файл | Что это | +|---|---| +| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | **единственный зовущий**, мой собственный файл сторожей | + +**Боевых зовущих — НОЛЬ.** Замер владельца подтверждаю: смена подписи не сломала +ничего, кроме собственных сторожей, которые я в этой же работе и переписал. + +### Зовущие `ObzvonClient::klyuchZadaniya()` — вызовы, поимённо + +| Файл | Что это | +|---|---| +| `app/app/Services/Obzvon/ObzvonClient.php` | сам себя — изнутри `pozvonit()` (это и есть починка) | +| `app/tests/Feature/Obzvon/ObzvonClientShovTest.php` | сторожа | + +### Упоминания, которые вызовами НЕ являются (чтобы не сосчитать их зовущими) + +| Файл | Почему не вызов | +|---|---| +| `bots/lena-golos/priyomnik.py` | питон робота; `pozvonit` там — имя HTTP-ручки, `ObzvonClient` — в пояснениях | +| `bots/lena-golos/proba-priyomnika.py` | то же, ~45 упоминаний имени ручки | +| `bots/lena-golos/README.md` | описание шва | +| `app/config/services.php` | комментарии-указатели | +| `app/.env.example` | комментарий про хвост ручки | +| `app/app/Services/Autopodbor/Agent/Fetch/DverRendera.php` | `{@see}` в докблоке (сосед, не тронут) | +| `app/app/Services/Obzvon/ObzvonOtvet.php` | `{@see}` в докблоке | +| `docs/superpowers/priyomka/stroyka-6/*.md` | приёмочные листы | + +Если считать «упоминания», зовущих выходит **больше десяти**. Вызовов — **два +файла на оба метода**, и оба мои. Именно на этой разнице сегодня обжёгся +надзиратель, о чём предупреждало задание. + +--- + +## 7. Что осталось незакрытым + +1. 🔴 **`8…` и `+7…` — сегодня разные люди для портала.** Один живой человек, + записанный двумя приставками, получит **два знака и два звонка**. Не чинил + намеренно (§1.4): решение «какие записи значат одного человека» — владельца, и + ошибка в противоположную сторону (слияние двух РАЗНЫХ людей в один знак) + тише и хуже. **Вопрос владельцу.** +2. 🔴 **`dopolnitelno` обязано быть одинаковым у повтора** (§1.5). Клиент это + гарантировать не может — содержимое даёт зовущий. Предупреждение написано в + докблоке `pozvonit`; сторожа на это нет и быть не может, пока нет зовущего. +3. 🔴 **Правило счёта попыток не назначено** (Р124). Моя работа его не делает + невозможным и выносит `popytkaNo` на видное место (§2.3), но само правило — + за владельцем, и **до него защита от двойного звонка не замкнута**. +4. 🔴 **Окно памяти робота — сутки.** Повтор той же попытки позже суток робот не + узнает и наберёт снова. Портальной починкой не лечится. +5. 🪤 **Защита по-прежнему не замкнута в бою**: доказано только то, что портал + шлёт согласованную пару. Что робот и вправду узнаёт повтор — проверяется на + стороне З-0.6, живого обмена между половинами не было. +6. **Зовущего у `pozvonit` до сих пор нет ни одного.** Работник очереди — отдельная + задача; пока он не написан, правильность применения (тот же `popytkaNo` на + повторе, то же `dopolnitelno`) ничем не охраняется.