Files
portal/app/tests/Unit/Sms/T2SmsProviderTest.php
T
Дмитрий 458f206ce9 feat(смс): канал Теле2 — отправка, судьба сообщений и приёмник отчётов
Канал «SMS-Таргет» (target.t2.ru, HTTP API v2, Basic Auth) обеими половинами:

- отправка через POST /send_message (msisdn числом, shortcode, text);
- опрос судьбы GET /send_message/message-id-{id} — по одному сообщению,
  пакетного запроса у t2 нет;
- приёмник обратных звонков GET|POST /api/webhook/t2-delivery/{secret}
  со строками ?id=&status=&parts= — форма другая, чем у МТС.

Ловушки, закрытые тестами:

- слово status в ответе встречается дважды: снаружи «запрос удался»,
  внутри судьба сообщения. Спутать = объявить доставленным всё подряд;
- отказ приезжает с HTTP 200 — судим по телу, не по коду;
- незнакомое слово статуса судьбу НЕ меняет, сохраняется в delivery_raw.
  За «не доставлено» клиенту возвращаются деньги (В-198) — выдумка двигала
  бы рубли;
- t2 наш ответ не проверяет и повторов не делает: пропущенный звонок потерян,
  поэтому опрос client-sms:poll-delivery остаётся обязательным.

Деньги приёмник не двигает — возврат живёт в команде опроса (В-146).
Защита приёмника как у МТС: секрет не короче 32 знаков (пустой = закрыт
наглухо), список адресов отправителя, счётчик обращений; чужому 404, не 403.

Канал ВЫКЛЮЧЕН и живьём не пробован: имя отправителя Liderra.ru (заявка
№ 31121) на согласовании, без него t2 не выдаёт логин с паролём.
Порядок включения — docs/superpowers/runbooks/2026-08-04-vklyuchenie-kanala-t2.md

Миграций нет: графы судьбы появились ещё при МТС. Теле2 уже был в списке
разрешённых операторов и в тарифах админки — новых экранов не нужно.

Проверено: 78 тестов зелёные, Pint чист, статанализ по ВСЕМУ проекту 0 ошибок,
маршрут зарегистрирован. Против main — только вставки, удалений нет.

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

300 lines
12 KiB
PHP

<?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([]);
});