Files
portal/app/tests/Feature/ClientSms/EnrichContactsOperatorTest.php
T
Дмитрий d4ccb5ac95 fix,смс: оператор и часовой пояс номера — из бесплатного справочника Россвязи
Модуль спрашивал и оператора, и регион ТОЛЬКО у платной ДаДаты, а без её
полезного ответа выходил молча. На бою это убило две трети модуля: рассылки
«по своей базе» и «списком руками» не отправляли ни одного сообщения —
причина skipped_unknown_operator, живой прогон 03.08.2026.

Теперь порядок такой: сперва ДаДата — только она знает про перенос номера к
другому оператору, — затем справочник phone_ranges, который дозаполняет
оставшееся пустым и никогда не затирает уже известное. Тем же справочником
страхуется приём лидов от поставщика.

Починено в трёх местах: обогащение своей базы номеров, догон снимка рассылки
и предпросмотр. Предпросмотр врал клиенту «ждут уточнения региона» про
номера, которые справочник знает наизусть, и смету на них не показывал.
Исчерпанный дневной лимит ДаДаты больше не обрывает догон целиком: бесплатный
справочник спрашивается всё равно, и остаток списка не ждёт следующего часа.

Сторож считает ЗАПРОСЫ: предпросмотр списка на 20 000 номеров, спрашивая
справочник по одному номеру, давал 20 044 запроса и 22 секунды на одно
нажатие кнопки. Пачками — 64 запроса и 4,6 секунды.

Тесты писались первыми и проверены красными; сторож пачечного запроса
проверен вырезанием. Модуль СМС 399 из 399, статанализ 0.
2026-08-03 12:05:13 +03:00

155 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\EnrichClientSmsContactsOperatorJob;
use App\Models\ClientSmsContact;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsRossvyazHint;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataPhoneClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Каждый добавленный в базу номер прогоняем через ДаДату, чтобы вытащить оператора
* (нужен для маршрутизации СМС по нужному провайдеру). ДаДата платная — расход
* ограничен DaDataBudgetGuard; сбой ДаДаты не должен ронять обогащение (оператор
* просто останется пустым).
*
* Синтетические номера 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
// ДаДата включена (ключ задан) — иначе обогащение не стартует.
config()->set('services.dadata.api_key', 'test-key');
config()->set('services.dadata.secret', 'test-secret');
Cache::flush(); // чистый дневной бюджет
});
it('проставляет оператора контактам без оператора', function () {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС']], 200)]);
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000001']);
(new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
expect($c->fresh()->operator)->toBe('МТС');
});
it('не трогает контакты, про которых уже всё известно (экономим ДаДату)', function () {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'Билайн']], 200)]);
// «Всё известно» = И оператор, И часовой пояс. С Этапа 3 одного оператора мало:
// без пояса номеру СМС не уходит вовсе (В-85), и такой контакт мы обязаны догнать.
$c = ClientSmsContact::create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000002',
'operator' => 'mts',
'tz_offset_minutes' => 180,
]);
(new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
expect($c->fresh()->operator)->toBe('mts'); // не перезаписан
Http::assertNothingSent(); // ДаДату не дёргали
});
it('3.3 — сохраняет часовой пояс из того же ответа, за который уже заплатили', function () {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => 'UTC+9']], 200)]);
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000005']);
(new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
// UTC+9 — это Якутск: девять часов от Гринвича, то есть 540 минут.
expect($c->fresh()->tz_offset_minutes)->toBe(540)
->and($c->fresh()->operator)->toBe('МТС');
// Второго обращения к ДаДате быть не должно: пояс приезжает тем же ответом.
Http::assertSentCount(1);
});
it('3.3 — непонятный пояс не мешает сохранить оператора', function () {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => 'ерунда']], 200)]);
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000006']);
(new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
// Ради оператора джоб и ходит — непонятный пояс не должен ронять главное.
expect($c->fresh()->operator)->toBe('МТС')
->and($c->fresh()->tz_offset_minutes)->toBeNull();
});
it('3.3 — догоняет уже загруженный номер: оператор есть, пояса нет', function () {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС', 'timezone' => 'UTC+2']], 200)]);
// Номера, загруженные ДО Этапа 3: оператора им проставили, а пояса тогда не было
// вовсе. Без догона такой номер после выката не получил бы СМС никогда (В-88).
$c = ClientSmsContact::create([
'tenant_id' => $this->tenant->id,
'phone' => '79990000007',
'operator' => 'МТС',
]);
(new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
expect($c->fresh()->tz_offset_minutes)->toBe(120)
->and($c->fresh()->operator)->toBe('МТС');
});
it('сбой ДаДаты не роняет обогащение — оператор остаётся пустым', function () {
Http::fake(['cleaner.dadata.ru/*' => Http::response('boom', 500)]);
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000003']);
(new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
expect($c->fresh()->operator)->toBeNull();
});
it('при исчерпанном дневном бюджете ДаДату не дёргает', function () {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'provider' => 'МТС']], 200)]);
// выбираем весь дневной лимит
config()->set('services.dadata.daily_cap_rub', 1);
app(DaDataBudgetGuard::class)->recordSpend(200); // 2 ₽ > 1 ₽
$c = ClientSmsContact::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000004']);
(new EnrichClientSmsContactsOperatorJob($this->tenant->id))->handle(
app(DaDataPhoneClient::class),
app(DaDataBudgetGuard::class),
app(ClientSmsRossvyazHint::class),
);
expect($c->fresh()->operator)->toBeNull();
Http::assertNothingSent();
});