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