458f206ce9
Канал «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>
230 lines
9.2 KiB
PHP
230 lines
9.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AdWalletTransaction;
|
|
use App\Models\ClientSmsCampaign;
|
|
use App\Models\ClientSmsMessage;
|
|
use App\Models\Tenant;
|
|
use App\Services\Sms\Providers\T2SmsProvider;
|
|
use App\Services\Sms\SmsDeliveryState;
|
|
use App\Services\Sms\SmsRouter;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Carbon;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
/**
|
|
* Строка листа 5.2 для Теле2: t2 сам зовёт нас при каждой смене состояния.
|
|
*
|
|
* 🔴 Форма у t2 ДРУГАЯ, чем у МТС: это не письмо с телом, а строки в адресе —
|
|
* `?id=<uid>&status=<status>&parts=<parts>` (плюс `err` при `rejected`).
|
|
*
|
|
* 🔴 t2 ответа не проверяет и повторов НЕ делает: пропущенный звонок потерян
|
|
* навсегда. Поэтому опрос `client-sms:poll-delivery` остаётся и здесь — приёмник
|
|
* только ускоряет, а не заменяет.
|
|
*
|
|
* 🪤 SharesSupplierPdo обязателен: приёмник кросс-клиентский и правит журнал
|
|
* СЛУЖЕБНЫМ соединением. Без общего PDO оно не видит незакоммиченных данных теста
|
|
* и находит НОЛЬ — прогон позеленел бы ВРУЩИ (урок В-95).
|
|
*
|
|
* Помощники с префиксом t2Hook* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
|
* Телефоны только синтетические 7999… — реальные НИКОГДА.
|
|
*/
|
|
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
|
|
|
/** Секрет приёмника: поедет в адресе, короче 32 знаков не принимаем. */
|
|
const T2_HOOK_SECRET = 't2-delivery-secret-0123456789abcdef';
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow('2026-08-15 12:00:00');
|
|
|
|
config(['services.sms.tele2.webhook_secret' => T2_HOOK_SECRET]);
|
|
config(['services.sms.tele2.webhook_ips' => '']);
|
|
|
|
// Разбор живёт в КАНАЛЕ. В песочнице маршрутизатор держит только заглушку,
|
|
// поэтому канал Теле2 ставим руками — в сеть он здесь не ходит.
|
|
app()->instance(SmsRouter::class, new SmsRouter([
|
|
new T2SmsProvider('login-not-used-here', 'pass', ['tele2'], ['*' => 692]),
|
|
]));
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
/**
|
|
* @param array<string, string> $params
|
|
*/
|
|
function t2HookUrl(array $params = [], string $secret = T2_HOOK_SECRET): string
|
|
{
|
|
$query = $params === [] ? '' : '?'.http_build_query($params);
|
|
|
|
return '/api/webhook/t2-delivery/'.$secret.$query;
|
|
}
|
|
|
|
function t2HookMessage(string $providerMessageId = 'message-id-VvkmLHkfhCX9', string $phone = '79990000001'): ClientSmsMessage
|
|
{
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$campaign = ClientSmsCampaign::create([
|
|
'tenant_id' => $tenant->id,
|
|
'title' => 'Отчёт от Теле2',
|
|
'body' => 'Текст',
|
|
'sender_name' => 'Liderra',
|
|
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
|
'status' => ClientSmsCampaign::STATUS_DONE,
|
|
'idempotency_key' => 't2-hook-'.uniqid(),
|
|
'segments' => 1,
|
|
'planned_count' => 1,
|
|
'sent_count' => 1,
|
|
'total_sms' => 1,
|
|
'price_rub_per_sms' => '6.92',
|
|
'estimated_cost_rub' => '6.92',
|
|
]);
|
|
|
|
return ClientSmsMessage::create([
|
|
'tenant_id' => $tenant->id,
|
|
'campaign_id' => $campaign->id,
|
|
'phone' => $phone,
|
|
'operator' => 'tele2',
|
|
'provider_key' => 'tele2',
|
|
'status' => ClientSmsMessage::STATUS_SENT,
|
|
'provider_message_id' => $providerMessageId,
|
|
'cost_rub' => '6.92',
|
|
'segments' => 1,
|
|
]);
|
|
}
|
|
|
|
it('принимает звонок t2, правит строку журнала и отвечает без содержимого', function () {
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->get(t2HookUrl([
|
|
'id' => 'message-id-VvkmLHkfhCX9',
|
|
'status' => 'delivered',
|
|
'parts' => '2',
|
|
]));
|
|
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED)
|
|
->and($message->delivery_raw)->toBe('delivered')
|
|
->and($message->provider_parts)->toBe(2)
|
|
->and($message->delivered_at)->not->toBeNull()
|
|
->and($message->delivery_checked_at)->not->toBeNull();
|
|
});
|
|
|
|
it('понимает отказ без числа частей', function () {
|
|
// parts не приходит при rejected и insufficient_balance.
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->get(t2HookUrl([
|
|
'id' => 'message-id-VvkmLHkfhCX9',
|
|
'status' => 'insufficient_balance',
|
|
]));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_SENT);
|
|
});
|
|
|
|
it('не пускает чужой адрес и журнал не трогает', function () {
|
|
config(['services.sms.tele2.webhook_ips' => '203.0.113.10, 203.0.113.11']);
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
|
->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered']));
|
|
|
|
// 404, а не 403: существование адреса чужому не подтверждаем.
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('пускает адрес из списка разрешённых', function () {
|
|
config(['services.sms.tele2.webhook_ips' => '203.0.113.10, 198.51.100.0/24']);
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
|
->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered']));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED);
|
|
});
|
|
|
|
it('не пускает с неверным секретом и журнал не трогает', function () {
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->get(t2HookUrl(
|
|
['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'],
|
|
'sovsem-drugoy-sekret-0123456789abcd',
|
|
));
|
|
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('закрыт наглухо, пока секрет не задан', function () {
|
|
config(['services.sms.tele2.webhook_secret' => '']);
|
|
$message = t2HookMessage();
|
|
|
|
// Пустой секрет — это «приёмник не настроен», а не «пускать всех».
|
|
$response = $this->get(t2HookUrl(
|
|
['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'],
|
|
'chto-ugodno-0123456789abcdefghij',
|
|
));
|
|
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('на звонок про незнакомое сообщение не падает', function () {
|
|
$message = t2HookMessage(providerMessageId: 'message-id-VvkmLHkfhCX9');
|
|
|
|
$response = $this->get(t2HookUrl(['id' => 'message-id-CHUZHOY', 'status' => 'delivered']));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('незнакомое слово оператора судьбу НЕ меняет', function () {
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->get(t2HookUrl([
|
|
'id' => 'message-id-VvkmLHkfhCX9',
|
|
'status' => 'kakoe_to_novoe_slovo',
|
|
]));
|
|
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
// Судьба пустая, слово сохранено как есть — угадать значило бы соврать про деньги.
|
|
expect($message->delivery_status)->toBeNull()
|
|
->and($message->delivery_raw)->toBe('kakoe_to_novoe_slovo');
|
|
});
|
|
|
|
it('на звонок непонятной формы не пишет ничего', function () {
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->get(t2HookUrl(['message_uid' => 'message-id-VvkmLHkfhCX9', 'state' => 'ok']));
|
|
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
expect($message->delivery_status)->toBeNull()
|
|
->and($message->delivery_raw)->toBeNull()
|
|
->and($message->delivery_checked_at)->toBeNull();
|
|
});
|
|
|
|
it('денег НЕ двигает — возврат остаётся делом опроса', function () {
|
|
$message = t2HookMessage();
|
|
|
|
$response = $this->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'failed']));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_DELIVERED)
|
|
->and($message->refresh()->refunded_at)->toBeNull()
|
|
// Деньги живут в ОДНОМ доме — в команде опроса (В-146).
|
|
// 🪤 Считаем проводки СВОЕГО клиента: рекламные тесты идут без отката и
|
|
// оставляют свои записи в базе (урок из MtsDeliveryWebhookTest).
|
|
->and(AdWalletTransaction::where('tenant_id', $message->tenant_id)->count())->toBe(0);
|
|
});
|