Files
portal/app/tests/Feature/ClientSms/BeelineDeliveryWebhookTest.php
T
Дмитрий 272ccbffe0 feat,смс: приёмник статусов Билайна — платформа сама сообщает судьбу
Билайн умеет звонить нам сам: POST на /api/webhook/beeline-delivery/{secret}
с полями ORDID, CNRID, RESCOUNT, STATUS, FINALTIME. Раньше судьбу сообщения
добирал только опрос — теперь она приходит сразу, как у МТС и Теле2.

Защита та же, что у двух соседних приёмников: секрет короче 32 знаков или
пустой = приёмник закрыт наглухо; сверка секрета через hash_equals; список
адресов отправителя необязательный, пустой список объявляется в журнале;
ограничитель 600 обращений в минуту; на чужой адрес и на неверный секрет
отвечаем 404, а не 403, чтобы не подсказывать чужому, что дверь тут есть.

Записи в журнал уровня warning, не notice: на бою LOG_LEVEL=warning и всё,
что ниже, не доезжает вовсе.

12 тестов написаны до кода, каждый посмотрен красным. Защита проверена
подсовыванием ошибки — краснеют ровно те тесты, что должны. Модуль целиком
530/530, pint чисто, статанализ 0.

Адрес приёмника в кабинете Билайна ещё не заявлен и секрет на бою не задан.
Пока их нет, приёмник закрыт, а канал работает по-старому через опрос.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:38:11 +03:00

269 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\BeelineSmsProvider;
use App\Services\Sms\SmsDeliveryState;
use App\Services\Sms\SmsRouter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строка листа 5.2 для Билайна: платформа сама зовёт нас, как только узнает судьбу.
*
* 🔴 Форма ТРЕТЬЯ, не как у МТС и не как у Теле2: описание платформы прямо говорит
* «немедленно перешлёт на указанный URL по протоколу HTTPS **методом POST**», поля —
* `ORDID`, `CNRID`, `RESCOUNT`, `STATUS`, `FINALTIME`. Судьба приезжает ЧИСЛОМ,
* а не словом, и время — в UTC. Разбор этого уже живёт в канале и покрыт своими
* тестами; здесь проверяется сама ручка.
*
* 🔴 Это НАДСТРОЙКА над опросом `client-sms:poll-delivery`, а не замена. Повторяет
* ли платформа неудавшийся звонок — в описании не сказано ни слова, поэтому считаем
* худшее: пропущенный звонок потерян навсегда. Отсюда правило — любой отказ приёмника
* безобиден, всё доберёт ближайший заход опроса.
*
* 🪤 SharesSupplierPdo обязателен: приёмник кросс-клиентский и правит журнал
* СЛУЖЕБНЫМ соединением. Без общего PDO оно не видит незакоммиченных данных теста
* и находит НОЛЬ — прогон позеленел бы ВРУЩИ (урок В-95).
*
* Помощники с префиксом beeHook* — имена функций в Pest ГЛОБАЛЬНЫЕ.
* Телефоны только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
/** Секрет приёмника: поедет в адресе, короче 32 знаков не принимаем. */
const BEE_HOOK_SECRET = 'beeline-delivery-secret-0123456789';
beforeEach(function () {
Carbon::setTestNow('2026-08-15 12:00:00');
config(['services.sms.beeline.webhook_secret' => BEE_HOOK_SECRET]);
config(['services.sms.beeline.webhook_ips' => '']);
// Разбор живёт в КАНАЛЕ. В песочнице маршрутизатор держит только заглушку,
// поэтому канал Билайна ставим руками — в сеть он здесь не ходит.
app()->instance(SmsRouter::class, new SmsRouter([
new BeelineSmsProvider('login-not-used-here', 'pass', ['beeline'], ['*' => 475]),
]));
});
afterEach(function () {
Carbon::setTestNow();
});
function beeHookUrl(string $secret = BEE_HOOK_SECRET): string
{
return '/api/webhook/beeline-delivery/'.$secret;
}
function beeHookMessage(string $providerMessageId = '508909732338141600334', string $phone = '79990000001'): ClientSmsMessage
{
$tenant = Tenant::factory()->create();
$campaign = ClientSmsCampaign::create([
'tenant_id' => $tenant->id,
'title' => 'Отчёт от Билайна',
'body' => 'Текст',
'sender_name' => 'Liderra.ru',
'source' => ClientSmsCampaign::SOURCE_MANUAL,
'status' => ClientSmsCampaign::STATUS_DONE,
'idempotency_key' => 'bee-hook-'.uniqid(),
'segments' => 1,
'planned_count' => 1,
'sent_count' => 1,
'total_sms' => 1,
'price_rub_per_sms' => '4.75',
'estimated_cost_rub' => '4.75',
]);
return ClientSmsMessage::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => $phone,
'operator' => 'beeline',
'provider_key' => 'beeline',
'status' => ClientSmsMessage::STATUS_SENT,
'provider_message_id' => $providerMessageId,
'cost_rub' => '4.75',
'segments' => 1,
]);
}
it('принимает звонок Билайна, правит строку журнала и отвечает без содержимого', function () {
$message = beeHookMessage();
$response = $this->post(beeHookUrl(), [
'ORDID' => '508909732338141600334',
'CNRID' => '1046802',
'RESCOUNT' => '2',
'STATUS' => '2',
'FINALTIME' => '15.08.2026 09:30:00',
]);
$response->assertNoContent();
$message->refresh();
expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED)
->and($message->delivery_raw)->toBe('2')
->and($message->provider_parts)->toBe(2)
->and($message->delivered_at)->not->toBeNull()
->and($message->delivery_checked_at)->not->toBeNull();
});
it('«оператор не принял» — это НЕ доставлено, а не ушло вовсе', function () {
// За неотправленное клиент платить не должен (В-212): судьбы «не доставлено»
// и «не ушло» разные, и возврат за них считается по-разному.
$message = beeHookMessage();
$this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '8'])
->assertNoContent();
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_SENT);
});
it('«судьба неизвестна» судьбу НЕ проставляет', function () {
// 7 — платформа сама не знает. Это не «не дошло»; догадка тут стоила бы
// ложного возврата денег.
$message = beeHookMessage();
$this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '7'])
->assertNoContent();
$message->refresh();
expect($message->delivery_status)->toBeNull()
->and($message->delivery_raw)->toBe('7');
});
it('не пускает чужой адрес и журнал не трогает', function () {
config(['services.sms.beeline.webhook_ips' => '203.0.113.10, 203.0.113.11']);
$message = beeHookMessage();
$response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2']);
// 404, а не 403: существование адреса чужому не подтверждаем.
$response->assertNotFound();
expect($message->refresh()->delivery_status)->toBeNull();
});
it('пускает адрес из списка разрешённых', function () {
config(['services.sms.beeline.webhook_ips' => '203.0.113.10, 198.51.100.0/24']);
$message = beeHookMessage();
$this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9'])
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2'])
->assertNoContent();
expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED);
});
/**
* Пока белый список пуст, ворота открыты всем — и адрес звонившего негде взять:
* отказа нет, значит и записи «пришли с такого-то адреса» нет. Круг замкнут,
* список не заполнить никогда. Разрываем его: при пустом списке пишем адрес.
*/
it('при пустом белом списке записывает адрес звонившего', function () {
config(['services.sms.beeline.webhook_ips' => '']);
Log::spy();
beeHookMessage();
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77'])
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2'])
->assertNoContent();
// Уровень именно «предупреждение». На боевом LOG_LEVEL=warning, и всё, что
// ниже, молча выбрасывается: записанная уровнем «к сведению» строка не
// появилась бы НИКОГДА — замерено живым вызовом на бою 04.08.2026.
/* @phpstan-ignore-next-line staticMethod.notFound */
Log::shouldHaveReceived('warning')
->withArgs(fn ($msg, $ctx) => $msg === 'client_sms.delivery_hook_open_gate'
&& $ctx['provider'] === 'beeline'
&& $ctx['ip'] === '203.0.113.77')
->once();
});
it('когда белый список задан — про адрес молчит', function () {
config(['services.sms.beeline.webhook_ips' => '203.0.113.0/24']);
Log::spy();
beeHookMessage();
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77'])
->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '2'])
->assertNoContent();
// Молчит именно про адрес: прочие предупреждения приёмника этот тест не трогает.
/* @phpstan-ignore-next-line staticMethod.notFound */
Log::shouldNotHaveReceived('warning', [
'client_sms.delivery_hook_open_gate',
Mockery::any(),
]);
});
it('не пускает с неверным секретом и журнал не трогает', function () {
$message = beeHookMessage();
$response = $this->post(
beeHookUrl('sovsem-drugoy-sekret-0123456789abcd'),
['ORDID' => '508909732338141600334', 'STATUS' => '2'],
);
$response->assertNotFound();
expect($message->refresh()->delivery_status)->toBeNull();
});
it('закрыт наглухо, пока секрет не задан', function () {
config(['services.sms.beeline.webhook_secret' => '']);
$message = beeHookMessage();
// Пустой секрет — это «приёмник не настроен», а не «пускать всех».
$response = $this->post(
beeHookUrl('chto-ugodno-0123456789abcdefghij'),
['ORDID' => '508909732338141600334', 'STATUS' => '2'],
);
$response->assertNotFound();
expect($message->refresh()->delivery_status)->toBeNull();
});
it('на звонок про незнакомое сообщение не падает', function () {
$message = beeHookMessage(providerMessageId: '508909732338141600334');
$this->post(beeHookUrl(), ['ORDID' => '999999999999999999999', 'STATUS' => '2'])
->assertNoContent();
expect($message->refresh()->delivery_status)->toBeNull();
});
it('на звонок непонятной формы не пишет ничего', function () {
$message = beeHookMessage();
$this->post(beeHookUrl(), ['message_uid' => '508909732338141600334', 'state' => 'ok'])
->assertNoContent();
$message->refresh();
expect($message->delivery_status)->toBeNull()
->and($message->delivery_raw)->toBeNull()
->and($message->delivery_checked_at)->toBeNull();
});
it('денег НЕ двигает — возврат остаётся делом опроса', function () {
$message = beeHookMessage();
$this->post(beeHookUrl(), ['ORDID' => '508909732338141600334', 'STATUS' => '5'])
->assertNoContent();
$message->refresh();
expect($message->delivery_status)->toBe(SmsDeliveryState::NOT_DELIVERED)
->and($message->refunded_at)->toBeNull()
// Деньги живут в ОДНОМ доме — в команде опроса (В-146).
// 🪤 Считаем проводки СВОЕГО клиента: рекламные тесты идут без отката и
// оставляют свои записи в базе (урок из MtsDeliveryWebhookTest).
->and(AdWalletTransaction::where('tenant_id', $message->tenant_id)->count())->toBe(0);
});