Files
portal/app/tests/Unit/Sms/BeelineSmsProviderTest.php
T
Дмитрий de0b09331c
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 ошибок.

На бой НЕ выкачено — отдельным разрешением владельца.
2026-08-11 05:24:34 +03:00

274 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Sms\Providers\BeelineSmsProvider;
use App\Services\Sms\SmsDeliveryState;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsSendException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
const BEE_URL = 'https://a2p-sms-https.beeline.ru/proto/http/rest';
function bee_provider(string $token = ''): BeelineSmsProvider
{
return new BeelineSmsProvider(
login: '1834321',
password: 'secret',
serves: ['beeline'],
priceKopecks: ['*' => 475],
baseUrl: BEE_URL,
naming: 'Liderra.ru',
token: $token,
);
}
function bee_message(string $sender = 'Liderra.ru', int $segments = 1): SmsOutgoing
{
return new SmsOutgoing(
phone: '79990000001',
body: 'Здравствуйте!',
senderName: $sender,
operator: 'beeline',
segments: $segments,
);
}
/** Успешный ответ платформы на отправку. */
function bee_ok(string $id = '508909732338141600334', string $count = '1'): array
{
return [
'actions' => [[
'sms_group_id' => '894323467546261497508434',
'id' => $id,
'sms_type' => 'SENDSMS',
'phone' => '+79990000001',
'sms_res_count' => $count,
'message' => 'Здравствуйте!',
'action' => 'post_sms_action',
]],
'agt_id' => '47309602',
'date_report' => '15.10.2022 18:42:59',
];
}
/** Отправить и вернуть пойманное исключение; прошло без ошибки — провалить тест. */
function bee_thrown(array $body, int $status = 200): SmsSendException
{
Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response($body, $status)]);
try {
bee_provider()->send(bee_message());
} catch (SmsSendException $e) {
return $e;
}
throw new RuntimeException('ожидалось SmsSendException, но отправка прошла без ошибки');
}
it('называется beeline и обслуживает заявленные сети', function () {
expect(bee_provider()->key())->toBe('beeline')
->and(bee_provider()->servesOperators())->toBe(['beeline']);
});
it('успешная отправка возвращает номер сообщения и цену', function () {
Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(), 200)]);
$result = bee_provider()->send(bee_message());
expect($result->providerMessageId)->toBe('508909732338141600334')
->and($result->segments)->toBe(1)
->and($result->costKopecks)->toBe(475);
Http::assertSent(function ($request) {
return $request->url() === BEE_URL
&& $request->method() === 'POST'
&& $request['user'] === '1834321'
&& $request['pass'] === 'secret'
&& $request['action'] === 'post_sms'
&& $request['sender'] === 'Liderra.ru'
&& $request['message'] === 'Здравствуйте!'
// Номер уходит строкой в международном виде — так в примерах Билайна.
&& $request['target'] === '+79990000001';
});
});
it('за деньги отвечает счётчик частей, названный платформой, а не наша оценка', function () {
// Мы посчитали 1 часть, платформа насчитала 2 — платить придётся за 2.
Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(count: '2'), 200)]);
$result = bee_provider()->send(bee_message(segments: 1));
expect($result->segments)->toBe(2)
->and($result->costKopecks)->toBe(950);
});
it('по ключу ходит без логина и пароля, с заголовком X-ApiKey', function () {
Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(), 200)]);
bee_provider(token: 'GvEqzI1TSmw7bDn')->send(bee_message());
Http::assertSent(function ($request) {
return $request->hasHeader('X-ApiKey', 'ApiKey GvEqzI1TSmw7bDn')
// 🔴 Вместе с ключом логин с паролем слать НЕЛЬЗЯ — платформа откажет.
&& ! isset($request['user'])
&& ! isset($request['pass']);
});
});
it('чужое имя отправителя не подменяет, а своё приводит к согласованному написанию', function () {
Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response(bee_ok(), 200)]);
bee_provider()->send(bee_message(sender: 'liderra.ru'));
bee_provider()->send(bee_message(sender: 'ChuzhoeImya'));
$otpravleno = [];
Http::assertSent(function ($request) use (&$otpravleno) {
$otpravleno[] = $request['sender'];
return true;
});
expect($otpravleno)->toBe(['Liderra.ru', 'ChuzhoeImya']);
});
it('отказ платформы без номера сообщения отправкой не считает', function () {
$e = bee_thrown(['error' => ['code' => '20200', 'message' => 'invalid request']]);
expect($e->getMessage())->toContain('20200');
});
it('неодобренное оператором имя отправителя — отказ окончательный', function () {
// 20230: «Отправитель X не одобрен на стороне оператора Y». Повтор не поможет,
// пока имя не согласуют — держать такое в очереди значит копить мусор.
$e = bee_thrown(['error' => ['code' => '20230', 'message' => 'Отправитель не одобрен']]);
expect($e->terminal)->toBeTrue();
});
it('упёрлись в суточный предел — отказ ВРЕМЕННЫЙ, завтра пройдёт', function () {
$e = bee_thrown(['error' => ['code' => '20280', 'message' => 'Достигнут суточный лимит']]);
expect($e->terminal)->toBeFalse();
});
// 🪤 Две подделки сети в ОДНОМ тесте ставить нельзя: второй вызов Http::fake не
// заменяет первую, а добавляется к ней, и срабатывает всё равно первая. Поэтому
// каждый код ответа проверяем отдельным тестом.
it('сбой на стороне Билайна — отказ временный', function () {
expect(bee_thrown([], 503)->terminal)->toBeFalse();
});
it('нас не пустили — отказ окончательный, повтор не поможет', function () {
expect(bee_thrown([], 401)->terminal)->toBeTrue();
});
it('не достучались — отказ временный', function () {
Http::fake(function () {
throw new ConnectionException('cURL error 28');
});
try {
bee_provider()->send(bee_message());
throw new RuntimeException('ожидалось SmsSendException');
} catch (SmsSendException $e) {
expect($e->terminal)->toBeFalse();
}
});
it('судьбу спрашивает пачкой и разбирает доставленное', function () {
Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response([
'actions' => [[
'sms_id' => '508909732338141600334',
'target' => '79990000001',
'sms_count' => '2',
'stc_code' => 'delivered',
'close_time' => '15.10.2022 18:42:59 ',
'status' => 'Сообщение доставлено',
'action' => 'status_action',
]],
], 200)]);
$reports = bee_provider()->fetchDelivery(['508909732338141600334']);
expect($reports)->toHaveCount(1)
->and($reports[0]->providerMessageId)->toBe('508909732338141600334')
->and($reports[0]->state)->toBe(SmsDeliveryState::DELIVERED)
->and($reports[0]->phone)->toBe('79990000001')
->and($reports[0]->partCount)->toBe(2)
->and($reports[0]->deliveredAt)->not->toBeNull();
Http::assertSent(fn ($request) => $request['action'] === 'status'
&& $request['sms_id'] === '508909732338141600334');
});
it('незнакомое слово о судьбе не угадывает', function () {
Http::fake(['a2p-sms-https.beeline.ru/*' => Http::response([
'actions' => [[
'sms_id' => '1',
'stc_code' => 'nechto-novoe',
]],
], 200)]);
$reports = bee_provider()->fetchDelivery(['1']);
// 🔴 Ноль вместо догадки: за «не доставлено» клиенту возвращаются деньги.
expect($reports[0]->state)->toBeNull()
->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'];
// 🪤 Ровно ОДНА подделка на тест, ответы по очереди: повторный Http::fake
// не заменяет прежний, и все опросы получали бы первый ответ.
$ochered = Http::fakeSequence('a2p-sms-https.beeline.ru/*');
foreach ($slova as $slovo) {
$ochered->push(['actions' => [['sms_id' => '1', 'stc_code' => $slovo]]], 200);
}
$sudby = [];
foreach ($slova as $slovo) {
$sudby[$slovo] = bee_provider()->fetchDelivery(['1'])[0]->state;
}
expect($sudby['accepted'])->toBe(SmsDeliveryState::SENDING)
// Оператор отклонил и отменённое пользователем — сообщение НЕ ушло,
// платить за это клиент не должен.
->and($sudby['rejected'])->toBe(SmsDeliveryState::NOT_SENT)
->and($sudby['aborted'])->toBe(SmsDeliveryState::NOT_SENT)
// Абонент недоступен — ушло, но не дошло.
->and($sudby['undeliverable'])->toBe(SmsDeliveryState::NOT_DELIVERED);
});