Files
portal/app/tests/Feature/ClientSms/MtsDeliveryWebhookTest.php
T

238 lines
11 KiB
PHP
Raw Normal View History

<?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). Приёмник только
// проставляет судьбу; возврат сделает ближайший заход опроса.
// 🪤 Считаем проводки СВОЕГО клиента, а не всей базы: три десятка рекламных
// тестов идут без отката (в tests/Pest.php строка RefreshDatabase закомментирована)
// и оставляют свои проводки в базе. Общий счёт по таблице делал этот тест
// заложником соседей — в полном прогоне он видел 19 чужих записей и падал,
// хотя приёмник вёл себя правильно. Смысл проверки не изменился: приёмник
// не имеет права двигать деньги того клиента, чьё сообщение он тронул.
->and(AdWalletTransaction::where('tenant_id', $message->tenant_id)->count())->toBe(0);
});