Files
portal/app/tests/Unit/Sms/T2SmsProviderTest.php
T
Дмитрий 0d0978a656 fix(смс): Теле2 не принимал имя отправителя в чужом написании
Первая клиентская рассылка на Теле2 упала: invalid_source_address.

Причина не опечатка, а устройство: имена регистрируются У КАЖДОГО ОПЕРАТОРА
ОТДЕЛЬНО, и написание разное — у МТС согласовано `liderra.ru`, у t2
`Liderra.ru`. Клиентская рассылка несёт ОДНО имя на всех получателей
(campaign.sender_name): имя выбирается на кампанию, а не на канал. Для МТС
оно верное, для t2 — нет.

Проверено опытом на живом t2, а не рассуждением:
  имя [liderra.ru] → ОТКАЗ: invalid_source_address
  имя [Liderra.ru] → ПРИНЯТО, message-id-kDQU1vVzEMbT

Лечение: канал приводит написание к согласованному с t2 — но ТОЛЬКО когда это
то же самое имя, отличающееся регистром. Чужое имя не трогаем: подменить его
значило бы соврать про отправителя, а честный отказ t2 лучше тихой подмены.
Пустое имя, как и раньше, заменяется именем канала.

🪤 Лечение половинчатое и так и подписано в коде: правильное — реестр имён по
каналам и в клиентском модуле (в модуле отдела продаж он уже есть,
SalesSmsSender::activeByProviderKey). Тогда эта склейка станет не нужна.

Четыре теста на разбор имени: тот же в другом регистре, точное совпадение,
чужое имя, пустое. Всего 82 теста зелёные, Pint чист, статанализ по всему
проекту 0 ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:15:31 +03:00

346 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Services\Sms\Providers\T2SmsProvider;
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;
function t2_provider(): T2SmsProvider
{
return new T2SmsProvider(
login: 'liderra_api',
password: 'secret',
serves: ['tele2'],
priceKopecks: ['*' => 250],
baseUrl: 'https://target.t2.ru/api/v2',
naming: 'Liderra',
);
}
function t2_message(string $sender = 'Liderra', int $segments = 1): SmsOutgoing
{
return new SmsOutgoing(
phone: '79990000001',
body: 'Здравствуйте!',
senderName: $sender,
operator: 'tele2',
segments: $segments,
);
}
/** Отправить и вернуть пойманное SmsSendException; если исключения не было — провалить тест. */
function t2_thrown(): SmsSendException
{
try {
t2_provider()->send(t2_message());
} catch (SmsSendException $e) {
return $e;
}
throw new RuntimeException('ожидалось SmsSendException, но отправка прошла без ошибки');
}
it('обслуживает только Теле2', function () {
expect(t2_provider()->servesOperators())->toBe(['tele2'])
->and(t2_provider()->key())->toBe('tele2');
});
it('успешная отправка возвращает номер сообщения и цену', function () {
Http::fake(['target.t2.ru/*' => Http::response(
['result' => ['uid' => 'message-id-VvkmLHkfhCX9'], 'status' => 'ok'],
200,
)]);
$result = t2_provider()->send(t2_message(segments: 2));
expect($result->providerMessageId)->toBe('message-id-VvkmLHkfhCX9')
->and($result->segments)->toBe(2)
->and($result->costKopecks)->toBe(500);
Http::assertSent(function ($request) {
return $request->url() === 'https://target.t2.ru/api/v2/send_message'
&& $request->method() === 'POST'
// Номер уходит ЧИСЛОМ по формату 7nnnNNNnnNN — так в документации t2.
&& $request['msisdn'] === 79990000001
&& $request['shortcode'] === 'Liderra'
&& $request['text'] === 'Здравствуйте!'
&& $request->hasHeader('Authorization', 'Basic '.base64_encode('liderra_api:secret'));
});
});
it('отказ с кодом 200 в теле — это отказ, а не успех', function () {
// 🪤 t2 отвечает 200 даже на отказ: судить надо по полю status, а не по коду HTTP.
Http::fake(['target.t2.ru/*' => Http::response(
['reason' => 'invalid_field_shortcode', 'status' => 'error'],
200,
)]);
$e = t2_thrown();
expect($e->getMessage())->toContain('invalid_field_shortcode')
->and($e->terminal)->toBeTrue();
});
it('сбой на стороне t2 — повтор осмыслен', function () {
Http::fake(['target.t2.ru/*' => Http::response('', 502)]);
expect(t2_thrown()->terminal)->toBeFalse();
});
it('связь оборвалась — это отказ канала, а не падение джоба', function () {
Http::fake(fn () => throw new ConnectionException('Connection timed out'));
$e = t2_thrown();
expect($e->terminal)->toBeFalse()
->and($e->getMessage())->toContain('Теле2 недоступен');
});
it('внутренняя ошибка t2 — повтор осмыслен, в отличие от кривого запроса', function () {
// system_error = «не смогли сохранить сообщение», это не наша вина: через
// минуту получится. Остальные reason'ы — наши, повторять их бесполезно.
Http::fake(['target.t2.ru/*' => Http::response(
['reason' => 'system_error', 'status' => 'error'],
200,
)]);
expect(t2_thrown()->terminal)->toBeFalse();
});
// ─── Судьба сообщений: «дошло или нет» (строки листа 5.1–5.2) ────────────────
/**
* Ответ t2 на опрос об одном сообщении — форма из документации «t2 HTTP API v2».
*
* @return array<string, mixed>
*/
function t2_pollBody(
string $status = 'delivered',
string $uid = 'message-id-VvkmLHkfhCX9',
mixed $pduCount = 2,
): array {
return [
'result' => [
'created_at' => 1574087003,
'msisdn' => 79990000001,
'pdu_count' => $pduCount,
'status' => $status,
'text' => 'Здравствуйте!',
'uid' => $uid,
'updated_at' => 1574087903,
],
'status' => 'ok',
];
}
it('за один заход спрашивает про ОДНО сообщение — пакетного запроса у t2 нет', function () {
expect(t2_provider()->deliveryBatchSize())->toBe(1);
});
it('спрашивает судьбу по номеру сообщения и понимает ответ', function () {
Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(), 200)]);
$reports = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']);
expect($reports)->toHaveCount(1);
$report = $reports[0];
expect($report->providerMessageId)->toBe('message-id-VvkmLHkfhCX9')
->and($report->phone)->toBe('79990000001')
->and($report->state)->toBe(SmsDeliveryState::DELIVERED)
->and($report->rawState)->toBe('delivered')
->and($report->partCount)->toBe(2)
// Время приходит ЧИСЛОМ (UNIX), а не строкой даты.
->and($report->happenedAt?->timestamp)->toBe(1574087903)
->and($report->deliveredAt?->timestamp)->toBe(1574087903);
Http::assertSent(function ($request) {
// Номер сообщения уже несёт приставку message-id- — второй раз её не клеим.
return $request->url() === 'https://target.t2.ru/api/v2/send_message/message-id-VvkmLHkfhCX9'
&& $request->method() === 'GET'
&& $request->hasHeader('Authorization', 'Basic '.base64_encode('liderra_api:secret'));
});
});
it('про пустой список не спрашивает вовсе', function () {
Http::fake();
expect(t2_provider()->fetchDelivery([]))->toBe([]);
Http::assertNothingSent();
});
it('спрашивает по очереди про каждое сообщение', function () {
Http::fake([
'target.t2.ru/api/v2/send_message/message-id-A' => Http::response(t2_pollBody(uid: 'message-id-A'), 200),
'target.t2.ru/api/v2/send_message/message-id-B' => Http::response(
t2_pollBody(status: 'failed', uid: 'message-id-B'), 200
),
]);
$reports = t2_provider()->fetchDelivery(['message-id-A', 'message-id-B']);
expect($reports)->toHaveCount(2)
->and($reports[0]->state)->toBe(SmsDeliveryState::DELIVERED)
->and($reports[1]->state)->toBe(SmsDeliveryState::NOT_DELIVERED);
Http::assertSentCount(2);
});
it('переводит слова t2 в судьбу портала', function (string $raw, ?string $expected) {
Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(status: $raw), 200)]);
$report = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0];
expect($report->state)->toBe($expected)
->and($report->rawState)->toBe($raw);
})->with([
['delivered', SmsDeliveryState::DELIVERED],
['awaiting_report', SmsDeliveryState::SENDING],
['failed', SmsDeliveryState::NOT_DELIVERED],
// Оператор ПРИНЯЛ, но не отправил — за такое клиент платить не должен (В-212).
['insufficient_balance', SmsDeliveryState::NOT_SENT],
['rejected', SmsDeliveryState::NOT_SENT],
// Незнакомое слово НЕ угадываем: за «не доставлено» возвращаются деньги (В-198).
['какое_то_новое_слово', null],
]);
it('время доставки ставит только у дошедшего', function () {
Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(status: 'awaiting_report'), 200)]);
$report = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0];
expect($report->deliveredAt)->toBeNull()
// Момент последней перемены знаем всегда — он и есть happenedAt.
->and($report->happenedAt?->timestamp)->toBe(1574087903);
});
it('пустое число частей не выдумывает', function () {
Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(pduCount: null), 200)]);
expect(t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0]->partCount)->toBeNull();
});
it('сбой при опросе — повтор осмыслен, судьбу можно переспросить позже', function () {
Http::fake(['target.t2.ru/*' => Http::response('', 500)]);
try {
t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']);
throw new RuntimeException('ожидалось SmsSendException');
} catch (SmsSendException $e) {
expect($e->terminal)->toBeFalse();
}
});
it('отказ t2 при опросе — тоже не окончательный', function () {
// 🪤 И здесь отказ приезжает с кодом 200: судить по полю status.
Http::fake(['target.t2.ru/*' => Http::response(['reason' => 'wrong_ip', 'status' => 'error'], 200)]);
try {
t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']);
throw new RuntimeException('ожидалось SmsSendException');
} catch (SmsSendException $e) {
expect($e->terminal)->toBeFalse()
->and($e->getMessage())->toContain('wrong_ip');
}
});
it('понимает обратный звонок t2 — он приходит строками в адресе, а не письмом', function () {
// t2 зовёт нас так: ...?id=<uid>&status=<status>&parts=<parts>
$reports = t2_provider()->parseDeliveryPayload([
'id' => 'message-id-VvkmLHkfhCX9',
'status' => 'delivered',
'parts' => '2',
]);
expect($reports)->toHaveCount(1);
$report = $reports[0];
expect($report->providerMessageId)->toBe('message-id-VvkmLHkfhCX9')
->and($report->state)->toBe(SmsDeliveryState::DELIVERED)
->and($report->rawState)->toBe('delivered')
->and($report->partCount)->toBe(2)
// Номера в звонке нет — выдумывать нечего, и для поиска строки он не нужен.
->and($report->phone)->toBe('');
});
it('обратный звонок без числа частей — так бывает при отказе', function () {
// parts не передаётся при rejected и insufficient_balance.
$report = t2_provider()->parseDeliveryPayload([
'id' => 'message-id-VvkmLHkfhCX9',
'status' => 'rejected',
'err' => 'blacklist',
])[0];
expect($report->partCount)->toBeNull()
->and($report->state)->toBe(SmsDeliveryState::NOT_SENT);
});
it('слово «ok» из конверта ответа за судьбу НЕ принимает', function () {
// 🪤 В ответе на опрос поле status встречается ДВАЖДЫ: снаружи это «запрос
// удался», внутри — судьба сообщения. Спутать их значит объявить доставленным
// всё подряд.
$reports = t2_provider()->parseDeliveryPayload(t2_pollBody(status: 'failed'));
expect($reports)->toHaveCount(1)
->and($reports[0]->state)->toBe(SmsDeliveryState::NOT_DELIVERED)
->and($reports[0]->rawState)->toBe('failed');
});
it('непонятную бумагу не разбирает — ноль записей, а не догадка', function () {
expect(t2_provider()->parseDeliveryPayload(['events_info' => [['x' => 1]]]))->toBe([])
->and(t2_provider()->parseDeliveryPayload([]))->toBe([])
// Есть слово, но нет номера сообщения — правит нечего.
->and(t2_provider()->parseDeliveryPayload(['status' => 'delivered']))->toBe([]);
});
/**
* 🔴 Имена отправителей регистрируются У КАЖДОГО ОПЕРАТОРА ОТДЕЛЬНО, и написание
* бывает разным: у МТС согласовано `liderra.ru`, у t2 — `Liderra.ru`.
*
* Клиентская рассылка несёт ОДНО имя на всех получателей (`campaign.sender_name`),
* и для t2 оно приезжает в чужом написании. Живой опыт на бою 04.08.2026:
* `liderra.ru` → отказ `invalid_source_address`, `Liderra.ru` → принято.
*
* Поэтому канал приводит написание к своему — но ТОЛЬКО когда это то же самое имя,
* отличающееся регистром. Чужое имя не трогаем: подменить его значило бы соврать
* про отправителя.
*/
it('имя отправителя приводит к написанию, согласованному с t2', function () {
Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]);
t2_provider()->send(t2_message(sender: 'liderra'));
Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra');
});
it('точное совпадение имени не трогает', function () {
Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]);
t2_provider()->send(t2_message(sender: 'Liderra'));
Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra');
});
it('чужое имя отправителя НЕ подменяет', function () {
// Когда у клиента появится своё согласованное имя, канал обязан слать его,
// а не наше. Отказ t2 в этом случае — честный ответ, а не повод врать.
Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]);
t2_provider()->send(t2_message(sender: 'ChuzhoeImya'));
Http::assertSent(fn ($request) => $request['shortcode'] === 'ChuzhoeImya');
});
it('пустое имя заменяет своим', function () {
Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]);
t2_provider()->send(t2_message(sender: ''));
Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra');
});