Files
portal/app/tests/Feature/Autopodbor/PhoneValidityGateTest.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

154 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Засеваем один диапазон Россвязи → реестр становится непустым.
* По умолчанию: def_code=921, абоненты 5550000..5559999 (МегаФон, СПб).
*
* @param array<string, mixed> $overrides
*/
function seedGateRange(array $overrides = []): void
{
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('b', 64),
'status' => 'completed',
'imported_at' => now(),
]);
DB::table('phone_ranges')->insert(array_merge([
'def_code' => 921,
'from_num' => 5550000,
'to_num' => 5559999,
'operator' => 'МегаФон',
'region' => 'Санкт-Петербург',
'subject_code' => 83,
'imported_at' => now(),
'import_id' => $importId,
], $overrides));
}
/**
* @param array<string, mixed> $overrides
* @return array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}
*/
function gateCandidate(string $number, array $overrides = []): array
{
return array_merge([
'number' => $number,
'kind' => 'text',
'label' => 'в тексте сайта — проверьте',
'url' => 'https://review-group.ru/',
'office' => null,
'tracker' => false,
], $overrides);
}
it('выкидывает номер, чей код города отсутствует в реестре Россвязи', function (): void {
seedGateRange(); // реестр непустой (есть 921)
// 76132306130 — код «613», такого в реестре нет (мусор со страницы-агрегатора).
$out = app(PhoneValidityGate::class)->filter([gateCandidate('76132306130')]);
expect($out)->toBe([]);
});
it('оставляет номер, который есть в реестре Россвязи', function (): void {
seedGateRange(); // def_code=921, 5550000..5559999
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('79215555123', ['kind' => 'code', 'label' => 'в коде сайта']),
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('79215555123');
});
it('фильтрует только мусор, реальные соседи остаются', function (): void {
seedGateRange();
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('76132306130'), // мусор (код 613)
gateCandidate('79215555123', ['kind' => 'code']), // реальный (921)
gateCandidate('72279347817'), // мусор (код 227 — вообще <300, запрещён реестром)
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('79215555123');
});
it('при пустом реестре не режет ничего (fail-open)', function (): void {
// seedGateRange НЕ вызван → phone_ranges пуст (как на dev). Фильтр не должен
// выкосить всё из-за незагруженного реестра.
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('76132306130'),
gateCandidate('79215555123'),
]);
expect($out)->toHaveCount(2);
});
it('никогда не трогает короткие номера «требует проверки» (их нельзя сверить)', function (): void {
seedGateRange();
// 2713333 — локальный 7-значный, код города не определён (kind=uncertain).
// Сверить с реестром нельзя → не теряем.
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('2713333', ['kind' => 'uncertain']),
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('2713333');
});
it('второй слой (ДаДата) снимает мусор, прошедший реестр', function (): void {
seedGateRange(); // 79215555123 есть в реестре
$dropAll = new class implements PhoneValidityLayer
{
public function isValid(string $number): bool
{
return false; // ДаДата сказала «мусор»
}
};
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $dropAll);
$out = $gate->filter([gateCandidate('79215555123', ['kind' => 'code'])]);
expect($out)->toBe([]);
});
it('второй слой НЕ вызывается для номеров вне реестра (мусор отсёк слой 1 бесплатно)', function (): void {
seedGateRange();
$spy = new class implements PhoneValidityLayer
{
public bool $called = false;
public function isValid(string $number): bool
{
$this->called = true;
return true;
}
};
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $spy);
// 76132306130 — код 613, вне реестра → слой 1 выкидывает бесплатно, ДаДата не дёргается.
$out = $gate->filter([gateCandidate('76132306130')]);
expect($out)->toBe([])
->and($spy->called)->toBeFalse();
});