From de0b09331cc1f6cdfbbcfccb7f2a423ece9e12e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 11 Aug 2026 05:24:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B8=D0=BB=D0=B0=D0=B9=D0=BD:=20=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=20=D1=81=D1=83=D0=B4=D1=8C=D0=B1=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D0=BB=20=D0=B3?= =?UTF-8?q?=D0=BB=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=BC=D0=BE=D0=BB=D1=87=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Беда. Платформа Билайна отдаёт отказ кодом ответа 200, а слова кладёт в тело: {"error":{"code":"-20107","message":"Ошибка авторизации"}}. Отправка это уже проверяла, а опрос судьбы — нет: fetchOne звал разбор напрямую, разбор поля `error` не смотрит и возвращал пустой список, неотличимый от честного «такого сообщения нет». Чем грозило. Сломается ключ — судьба сообщений просто перестанет обновляться, МОЛЧА. А за «не доставлено» клиенту возвращаются рубли (В-198), то есть молчание = невозвращённые деньги. Тот же класс, что 04.08 с уровнем журнала. Правка. В fetchOne перед разбором прогоняется уже готовый kodOshibki(); нашёлся код — бросается SmsSendException с terminal: false. Не окончательный намеренно: судьбу всегда можно переспросить следующим заходом, в отличие от отправки. Приёмник обратного звонка платформы не тронут — у него своя дорога. Кто ловит. PollClientSmsDeliveryCommand уже пишет Log::warning и идёт дальше; у Билайна пачка = 1 сообщение, так что отказ стоит одного номера за заход, а не всего прохода. Warning на бою слышен — ниже него журнал глотает (урок 04.08). Доказано, а не «должно работать». Тест написан ПЕРВЫМ и был увиден красным: без проверки падал ровно один тест, новый, со словами «опрос прошёл молча», остальные 15 стояли зелёными. Вторым тестом закрыта обратная сторона: ответ без отказа и без действий по-прежнему считается честным «сообщения нет» и НЕ роняет заход — иначе один забытый номер срывал бы весь опрос. Прогон: СМС 577/577, 7155 проверок (было 575/575 и 7151 — ровно на два теста и четыре проверки больше). Pint чист, PHPStan 0 ошибок. На бой НЕ выкачено — отдельным разрешением владельца. --- .../Sms/Providers/BeelineSmsProvider.php | 13 ++++++++ app/tests/Unit/Sms/BeelineSmsProviderTest.php | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/app/Services/Sms/Providers/BeelineSmsProvider.php b/app/app/Services/Sms/Providers/BeelineSmsProvider.php index f6923e28..f40ad6c1 100644 --- a/app/app/Services/Sms/Providers/BeelineSmsProvider.php +++ b/app/app/Services/Sms/Providers/BeelineSmsProvider.php @@ -263,6 +263,19 @@ final class BeelineSmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryR throw new SmsSendException($e->getMessage(), terminal: false); } + // 🪤 Та же ловушка, что при отправке: платформа отвечает кодом 200 и на + // отказ, а слова кладёт в тело. Промолчать здесь опаснее, чем при отправке: + // сломается ключ — судьба сообщений просто перестанет обновляться, и + // пустой ответ будет неотличим от честного «такого сообщения нет». + // А за недоставленное клиенту возвращаются рубли (В-198), то есть + // молчание = невозвращённые деньги. + if ($this->kodOshibki($otvet) !== '') { + throw new SmsSendException( + 'Билайн отказал в опросе судьбы: '.$this->tekstOshibki($otvet), + terminal: false, + ); + } + return $this->parseDeliveryPayload($otvet); } diff --git a/app/tests/Unit/Sms/BeelineSmsProviderTest.php b/app/tests/Unit/Sms/BeelineSmsProviderTest.php index d2c422ce..ab155c6d 100644 --- a/app/tests/Unit/Sms/BeelineSmsProviderTest.php +++ b/app/tests/Unit/Sms/BeelineSmsProviderTest.php @@ -218,6 +218,36 @@ it('незнакомое слово о судьбе не угадывает', fu ->and($reports[0]->rawState)->toBe('nechto-novoe'); }); +it('отказ платформы при опросе судьбы не проглатывает молча', function () { + // 🪤 Билайн отдаёт отказ кодом ответа 200, а слова кладёт в тело. Раньше + // разбор поля `error` не смотрел и возвращал пустоту, неотличимую от честного + // «такого сообщения нет»: сломался бы ключ — судьба перестала бы обновляться + // МОЛЧА, а за недоставленное клиенту возвращаются рубли (В-198). + Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response([ + 'error' => ['code' => '-20107', 'message' => 'Ошибка авторизации'], + ], 200)]); + + try { + bee_provider()->fetchDelivery(['508909732338141600334']); + throw new RuntimeException('ожидалось SmsSendException, но опрос прошёл молча'); + } catch (SmsSendException $e) { + // Судьбу всегда можно переспросить следующим заходом — отказ здесь + // НЕ окончательный, в отличие от отправки. + expect($e->terminal)->toBeFalse() + ->and($e->getMessage())->toContain('20107') + ->and($e->getMessage())->toContain('Ошибка авторизации'); + } +}); + +it('честное «такого сообщения нет» отказом не считает', function () { + // Обратная сторона: ответ без отказа и без действий — это НЕ поломка, а + // «платформа про такое сообщение ничего не знает». Ругаться здесь значило бы + // сорвать весь заход опроса из-за одного забытого номера. + Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(['actions' => []], 200)]); + + expect(bee_provider()->fetchDelivery(['1']))->toBe([]); +}); + it('принятое оператором и отклонённое им разводит по разным судьбам', function () { $slova = ['accepted', 'rejected', 'undeliverable', 'aborted'];