&status=&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 $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 () { config(['services.sms.tele2.webhook_ips' => '']); Log::spy(); t2HookMessage(); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77']) ->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'])) ->assertNoContent(); // Методы подставного журнала анализатору не видны — как у соседей по проекту. /* @phpstan-ignore-next-line staticMethod.notFound */ Log::shouldHaveReceived('notice') ->withArgs(fn ($msg, $ctx) => $msg === 'client_sms.delivery_hook_open_gate' && $ctx['provider'] === 'tele2' && $ctx['ip'] === '203.0.113.77') ->once(); }); it('когда белый список задан — про адрес молчит', function () { config(['services.sms.tele2.webhook_ips' => '203.0.113.0/24']); Log::spy(); t2HookMessage(); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77']) ->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'])) ->assertNoContent(); /* @phpstan-ignore-next-line staticMethod.notFound */ Log::shouldNotHaveReceived('notice'); }); 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); });