2026-08-04 15:04:52 +03:00
|
|
|
|
<?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([]);
|
|
|
|
|
|
});
|
2026-08-04 18:15:31 +03:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 Имена отправителей регистрируются У КАЖДОГО ОПЕРАТОРА ОТДЕЛЬНО, и написание
|
|
|
|
|
|
* бывает разным: у МТС согласовано `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');
|
|
|
|
|
|
});
|