Билайн: опрос судьбы перестал глотать отказ молча
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run

Беда. Платформа Билайна отдаёт отказ кодом ответа 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 ошибок.

На бой НЕ выкачено — отдельным разрешением владельца.
This commit is contained in:
Дмитрий
2026-08-11 05:24:34 +03:00
parent df70a78e17
commit de0b09331c
2 changed files with 43 additions and 0 deletions
@@ -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);
}
@@ -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'];