dd83f440eb
Строка листа 5.2. Появился адрес, на который МТС может присылать судьбу сообщения сам,
не дожидаясь нашего вопроса. В ответ отдаём код 204 — этого он требует, иначе считает
доставку неудачной и шлёт повторы.
Несущее решение здесь одно, и из него растёт всё остальное: приёмник — НАДСТРОЙКА, а не
замена. Опрос каждые десять минут остаётся на месте. Значит любой отказ приёмника
безобиден: не понял письмо, не узнал номер сообщения, вовсе выключен — всё доберёт опрос.
Поэтому везде выбран ноль вместо догадки, и это позволило честно работать при незнании.
А незнание крупное: формы письма, которое шлёт МТС, живьём не видел никто. Записано только,
что письма приходят и что отвечать надо кодом 204. Документации тут веры нет — она уже
соврала про опрос, описав одну форму вместо другой. Выдумывать я не стал (В-243): приёмник
разбирает ровно ту форму, которую видел живой ответ на опрос, а незнакомое письмо кладёт в
журнал сервера своей ФОРМОЙ — перечнем полей, без содержимого, потому что внутри телефоны, а
это персональные данные. Первый же живой отчёт покажет свою форму сам, и читатель дописается
одной правкой. Проверено живьём: телефон в журнал не утёк.
Правило «как отчёт ложится в журнал» переехало из команды опроса в общий дом на два входа.
Разъехавшись, они писали бы по-разному, а по журналу считаются деньги. Форма письма при этом
живёт в канале, приёмник про устройство МТС не знает ничего — новый оператор с кабинетом
вставляется, не трогая ни приёмника, ни журнала.
Деньги приёмник не двигает вовсе. Возврат за недоставленное остаётся в опросе, где у него
своя двойная защита от повтора; вторая дорога к кошельку означала бы вторую возможность
вернуть дважды. Отдельный тест это стережёт.
Адрес публичный, поэтому защита тройная: секрет в адресе (не задан — приёмник закрыт наглухо,
а не «пускать всех»), необязательный список адресов отправителя и счётчик обращений. Чужому —
404, существование приёмника не подтверждаем. Список адресов пока пуст: адреса МТС нам
неизвестны, и придумать их нельзя.
Десять тестов, все доказаны вырезом — вырезов вышло двенадцать. Два вырезали и не покрасили
ничего, и опять ошибалось моё ожидание, а не код (шестой раз): место оказалось защищено
несколькими независимыми строгостями, каждой хватало поодиночке. Снял по две и по три разом —
покраснели ровно те тесты. Заодно попался прибор: прогон один раз показал девять тестов
вместо десяти при нуле красных, то есть пропавшая работа выглядела как отсутствие проблем.
Живьём: верный отчёт правит строку и отвечает 204; чужой секрет — 404 и строка не тронута;
непонятное письмо — 204 и ни одной записи; отчёт про неизвестный номер — 204 и предупреждение;
адрес вне списка — 404. Кошелёк за весь прогон не шелохнулся. Стенд возвращён.
Включение — сторона владельца: адрес указывается в кабинете МТС. До этого всё работает опросом.
🔴 И честно: пока канал МТС на бою не отправляет ничего, проверить приёмник живым письмом
нечем — отчётам просто неоткуда взяться.
232 lines
9.8 KiB
PHP
232 lines
9.8 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\MtsSmsProvider;
|
|
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: МТС сам присылает отчёт о судьбе — приёмник ускоряет опрос.
|
|
*
|
|
* 🪤 SharesSupplierPdo обязателен: приёмник кросс-клиентский (у публичного адреса
|
|
* нет ни сессии, ни пометки клиента) и правит журнал СЛУЖЕБНЫМ соединением. Без
|
|
* общего PDO это соединение не видит незакоммиченных данных теста и находит НОЛЬ —
|
|
* прогон позеленел бы ВРУЩИ там, где мы ждём правку (урок В-95).
|
|
*
|
|
* 🔴 Форма письма от МТС нам НЕИЗВЕСТНА (журнал В-243) и здесь не выдумывается:
|
|
* проверяется ровно та форма, что видел живой ответ на опрос (В-211), плюс она же
|
|
* без конверта. Всё остальное обязано дать НОЛЬ записей, а не догадку.
|
|
*
|
|
* Помощники с префиксом mtsHook* — имена функций в Pest ГЛОБАЛЬНЫЕ.
|
|
* Телефоны только синтетические 7999… — реальные НИКОГДА.
|
|
*/
|
|
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
|
|
|
/** Секрет приёмника: у МТС он поедет в адресе, короче 32 знаков не принимаем. */
|
|
const MTS_HOOK_SECRET = 'mts-delivery-secret-0123456789abcdef';
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow('2026-08-15 12:00:00');
|
|
|
|
config(['services.sms.mts.webhook_secret' => MTS_HOOK_SECRET]);
|
|
config(['services.sms.mts.webhook_ips' => '']);
|
|
|
|
// Разбор письма живёт в КАНАЛЕ. В песочнице маршрутизатор держит только
|
|
// заглушку, поэтому канал МТС ставим руками — ходить в сеть он здесь не будет,
|
|
// работает только разбор.
|
|
app()->instance(SmsRouter::class, new SmsRouter([
|
|
new MtsSmsProvider('token-not-used-here', ['mts'], ['*' => 300]),
|
|
]));
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
function mtsHookUrl(string $secret = MTS_HOOK_SECRET): string
|
|
{
|
|
return '/api/webhook/mts-delivery/'.$secret;
|
|
}
|
|
|
|
function mtsHookMessage(string $providerMessageId = '44747238', string $phone = '79990000001'): ClientSmsMessage
|
|
{
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$campaign = ClientSmsCampaign::create([
|
|
'tenant_id' => $tenant->id,
|
|
'title' => 'Отчёт от МТС',
|
|
'body' => 'Текст',
|
|
'sender_name' => 'LIDERRA',
|
|
'source' => ClientSmsCampaign::SOURCE_MANUAL,
|
|
'status' => ClientSmsCampaign::STATUS_DONE,
|
|
'idempotency_key' => 'mts-hook-'.uniqid(),
|
|
'segments' => 1,
|
|
'planned_count' => 1,
|
|
'sent_count' => 1,
|
|
'total_sms' => 1,
|
|
'price_rub_per_sms' => '8.50',
|
|
'estimated_cost_rub' => '8.50',
|
|
]);
|
|
|
|
return ClientSmsMessage::create([
|
|
'tenant_id' => $tenant->id,
|
|
'campaign_id' => $campaign->id,
|
|
'phone' => $phone,
|
|
'operator' => 'mts',
|
|
'provider_key' => 'mts',
|
|
'status' => ClientSmsMessage::STATUS_SENT,
|
|
'provider_message_id' => $providerMessageId,
|
|
'cost_rub' => '8.50',
|
|
'segments' => 1,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Письмо в той форме, что видел ЖИВОЙ ответ МТС на опрос (В-211).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
function mtsHookBody(string $status, string $providerMessageId = '44747238', string $phone = '79990000001'): array
|
|
{
|
|
return [
|
|
'code' => 0,
|
|
'data' => [[
|
|
'messageID' => $providerMessageId,
|
|
'statuses' => [[
|
|
'msid' => $phone,
|
|
'status' => $status,
|
|
'date' => '2026-08-15 11:58:00',
|
|
'userDeliveryDate' => '2026-08-15 11:59:00',
|
|
'partCount' => 2,
|
|
'cost' => '6.0000',
|
|
]],
|
|
]],
|
|
];
|
|
}
|
|
|
|
it('принимает отчёт МТС, правит строку журнала и отвечает кодом 204', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('Delivered'));
|
|
|
|
// 204 требует сам МТС (В-207): иной код он считает неудачей и шлёт повторы.
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED)
|
|
->and($message->delivery_raw)->toBe('Delivered')
|
|
->and($message->provider_parts)->toBe(2)
|
|
->and((string) $message->provider_cost)->toBe('6.0000')
|
|
->and($message->delivered_at)->not->toBeNull()
|
|
->and($message->delivery_checked_at)->not->toBeNull();
|
|
});
|
|
|
|
it('понимает то же письмо без конверта — списком отчётов', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('Delivered')['data']);
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED);
|
|
});
|
|
|
|
it('не пускает чужой адрес и журнал не трогает', function () {
|
|
config(['services.sms.mts.webhook_ips' => '203.0.113.10, 203.0.113.11']);
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
|
->postJson(mtsHookUrl(), mtsHookBody('Delivered'));
|
|
|
|
// 404, а не 403: существование адреса чужому не подтверждаем.
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('пускает адрес из списка разрешённых', function () {
|
|
config(['services.sms.mts.webhook_ips' => '203.0.113.10, 198.51.100.0/24']);
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
|
|
->postJson(mtsHookUrl(), mtsHookBody('Delivered'));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED);
|
|
});
|
|
|
|
it('не пускает с неверным секретом и журнал не трогает', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl('sovsem-drugoy-sekret-0123456789abcd'), mtsHookBody('Delivered'));
|
|
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('закрыт наглухо, пока секрет не задан', function () {
|
|
config(['services.sms.mts.webhook_secret' => '']);
|
|
$message = mtsHookMessage();
|
|
|
|
// Пустой секрет — это «приёмник не настроен», а не «пускать всех»: пустую
|
|
// строку в адресе маршрут не примет, но и любой другой ключ не подойдёт.
|
|
$response = $this->postJson(mtsHookUrl('chto-ugodno-0123456789abcdefghij'), mtsHookBody('Delivered'));
|
|
|
|
$response->assertNotFound();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('на отчёт про незнакомый номер сообщения отвечает 204 и не падает', function () {
|
|
$message = mtsHookMessage(providerMessageId: '44747238');
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('Delivered', providerMessageId: '99999999'));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBeNull();
|
|
});
|
|
|
|
it('незнакомое слово оператора судьбу НЕ меняет', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('КакоеТоНовоеСлово'));
|
|
|
|
$response->assertNoContent();
|
|
|
|
$message->refresh();
|
|
// Судьба пустая, слово сохранено как есть — угадать значило бы соврать про деньги.
|
|
expect($message->delivery_status)->toBeNull()
|
|
->and($message->delivery_raw)->toBe('КакоеТоНовоеСлово');
|
|
});
|
|
|
|
it('на письмо непонятной формы отвечает 204 и НЕ пишет ничего', function () {
|
|
$message = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), ['events_info' => [['id' => '44747238', '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 = mtsHookMessage();
|
|
|
|
$response = $this->postJson(mtsHookUrl(), mtsHookBody('NotDelivered'));
|
|
|
|
$response->assertNoContent();
|
|
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_DELIVERED)
|
|
->and($message->refresh()->refunded_at)->toBeNull()
|
|
// Деньги живут в ОДНОМ доме — в команде опроса (В-146). Приёмник только
|
|
// проставляет судьбу; возврат сделает ближайший заход опроса.
|
|
->and(AdWalletTransaction::count())->toBe(0);
|
|
});
|