0d0978a656
Первая клиентская рассылка на Теле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>
346 lines
15 KiB
PHP
346 lines
15 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([]);
|
||
});
|
||
|
||
/**
|
||
* 🔴 Имена отправителей регистрируются У КАЖДОГО ОПЕРАТОРА ОТДЕЛЬНО, и написание
|
||
* бывает разным: у МТС согласовано `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');
|
||
});
|