de0b09331c
Беда. Платформа Билайна отдаёт отказ кодом ответа 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 ошибок.
На бой НЕ выкачено — отдельным разрешением владельца.
274 lines
12 KiB
PHP
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);
|
|
});
|