Files
portal/app/tests/Unit/Sms/T2SmsProviderTest.php
T

346 lines
15 KiB
PHP
Raw Normal View History

<?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');
});