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); });