Files
portal/app/tests/Unit/Autopodbor/Phone/DaDataValidityLayerTest.php
T
Дмитрий a028af4bc8
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
feat(autopodbor): сторож валидности телефонов на шаге 2 (реестр Россвязи + ДаДата)
Экстрактор кода страницы ловил любые 11 цифр (7/8 + телефонная группировка) без
проверки, что это настоящий российский номер — со страниц-агрегаторов пролезал мусор
с несуществующими кодами (227/303/453/613/761) и подписывался «городской».

PhoneValidityGate между CandidateBuilder и SourceAggregator:
- слой 1 (бесплатно, всегда): реестр Россвязи (phone_ranges) — нет в реестре → выкидываем;
- слой 2 (платно, за флагом autopodbor.phone_dadata_enabled, по умолчанию ВЫКЛ):
  DaDataValidityLayer перепроверяет прошедшие реестр номера, снимает qc 2/7,
  под дневным бюджетом + кэш вердикта; платим только за выживших (мусор отсекается ДО ДаДаты).
- fail-open при пустом реестре / сбое БД (иначе внешний catch в SiteOpener съел бы все номера);
- короткие/uncertain номера не трогаем (нельзя сверить).

Закрыты ОБА пути шага 2: обычный (RealCompetitorAgent) и глубокий (SiteOpener).
Миграций нет — реестр уже на проде (453k диапазонов).

Тесты: PhoneValidityGateTest (7), DaDataValidityLayerTest (6), SiteOpenerValidityTest (1);
весь набор автоподбора 519 зелёных, Pint + Larastan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:34:19 +03:00

50 lines
1.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
config(['services.dadata.daily_cap_rub' => 10000]); // бюджет открыт
});
it('qc=2 (мусор) → номер невалиден (выкидываем)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 2]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
});
it('qc=7 (иностранец) → номер невалиден', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 7]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
});
it('qc=0 (годный) → валиден', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});
it('qc=1 (неоднозначный) → оставляем (не режем по сомнению)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 1]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});
it('бюджет исчерпан → не тратим и не режем, ДаДату не дёргаем', function (): void {
config(['services.dadata.daily_cap_rub' => 0]); // касса закрыта
Http::fake();
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
Http::assertNothingSent();
});
it('сбой ДаДаты (сеть) → не роняем и не режем', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});