216 lines
7.8 KiB
PHP
216 lines
7.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\SupplierLead;
|
|
use App\Services\LeadRegionResolver;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
uses(SharesSupplierPdo::class);
|
|
|
|
beforeEach(function (): void {
|
|
config([
|
|
'services.dadata.enabled' => true,
|
|
'services.dadata.api_key' => 'k',
|
|
'services.dadata.secret' => 's',
|
|
'services.dadata.daily_cap_rub' => 10000,
|
|
]);
|
|
});
|
|
|
|
function resolverSeedImport(): int
|
|
{
|
|
return (int) DB::table('phone_ranges_imports')->insertGetId([
|
|
'source_url' => 'test', 'checksum_sha256' => str_repeat('b', 64),
|
|
'status' => 'completed', 'imported_at' => now(),
|
|
]);
|
|
}
|
|
|
|
function resolverSeedRange(int $subject, string $region = 'Москва', int $def = 916, string $operator = 'Ростелеком'): void
|
|
{
|
|
DB::table('phone_ranges')->insert([
|
|
'def_code' => $def, 'from_num' => 0, 'to_num' => 9999999,
|
|
'operator' => $operator, 'region' => $region, 'subject_code' => $subject,
|
|
'imported_at' => now(), 'import_id' => resolverSeedImport(),
|
|
]);
|
|
}
|
|
|
|
function resolverLead(string $phone = '79161234567', string $tag = ''): SupplierLead
|
|
{
|
|
return new SupplierLead([
|
|
'phone' => $phone,
|
|
'raw_payload' => ['tag' => $tag],
|
|
'received_at' => now(),
|
|
]);
|
|
}
|
|
|
|
function fakeDadata(array $row): void
|
|
{
|
|
Http::fake(['cleaner.dadata.ru/*' => Http::response([$row], 200)]);
|
|
}
|
|
|
|
it('dadata qc 0 returns dadata source', function (): void {
|
|
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный']);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('dadata')
|
|
->and($r->subjectCode)->toBe(82)
|
|
->and($r->phoneOperator)->toBe('МТС')
|
|
->and($r->qc)->toBe(0)
|
|
->and($r->cacheHit)->toBeFalse();
|
|
});
|
|
|
|
it('dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider', function (): void {
|
|
fakeDadata(['qc' => 0, 'region' => 'Санкт-Петербург и область', 'provider' => 'МегаФон']);
|
|
resolverSeedRange(subject: 83, region: 'Санкт-Петербург');
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('rossvyaz')
|
|
->and($r->subjectCode)->toBe(83)
|
|
->and($r->phoneOperator)->toBe('МегаФон') // оператор от DaData (MNP), §3.4.1
|
|
->and($r->rossvyazMatched)->toBeTrue();
|
|
});
|
|
|
|
it('dadata qc 3 returns dadata with multiple flag', function (): void {
|
|
fakeDadata(['qc' => 3, 'region' => 'Москва', 'provider' => 'МТС']);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('dadata')->and($r->subjectCode)->toBe(82)->and($r->qc)->toBe(3);
|
|
});
|
|
|
|
it('dadata qc 1 falls back to rossvyaz', function (): void {
|
|
fakeDadata(['qc' => 1, 'region' => 'Москва', 'provider' => 'Билайн']);
|
|
resolverSeedRange(subject: 82);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
|
});
|
|
|
|
it('dadata qc 2 falls back to tag skipping rossvyaz', function (): void {
|
|
fakeDadata(['qc' => 2]);
|
|
resolverSeedRange(subject: 83); // если бы Россвязь дёрнули — был бы 83
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
|
|
|
|
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82)->and($r->rossvyazMatched)->toBeFalse();
|
|
});
|
|
|
|
it('dadata qc 7 falls back to tag skipping rossvyaz', function (): void {
|
|
fakeDadata(['qc' => 7]);
|
|
resolverSeedRange(subject: 83);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
|
|
|
|
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
|
|
});
|
|
|
|
it('dadata timeout falls back to rossvyaz', function (): void {
|
|
Http::fake(fn () => throw new ConnectionException('timeout'));
|
|
resolverSeedRange(subject: 82);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
|
});
|
|
|
|
it('dadata network error 5xx falls back to rossvyaz', function (): void {
|
|
Http::fake(['cleaner.dadata.ru/*' => Http::response('err', 500)]);
|
|
resolverSeedRange(subject: 82);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
|
});
|
|
|
|
it('budget cap exceeded skips dadata directly to rossvyaz', function (): void {
|
|
config(['services.dadata.daily_cap_rub' => 0]); // canSpend() → false
|
|
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
|
|
resolverSeedRange(subject: 82);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('cache hit skips dadata and rossvyaz on the second call', function (): void {
|
|
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']);
|
|
$resolver = app(LeadRegionResolver::class);
|
|
|
|
$first = $resolver->resolve(resolverLead());
|
|
$second = $resolver->resolve(resolverLead());
|
|
|
|
expect($first->cacheHit)->toBeFalse()
|
|
->and($second->cacheHit)->toBeTrue()
|
|
->and($second->subjectCode)->toBe(82);
|
|
Http::assertSentCount(1);
|
|
});
|
|
|
|
it('invalid phone skips dadata returns tag', function (): void {
|
|
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead(phone: '123', tag: 'Москва'));
|
|
|
|
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('qc 0 region null falls through to rossvyaz', function (): void {
|
|
fakeDadata(['qc' => 0, 'region' => null, 'provider' => 'Tele2']);
|
|
resolverSeedRange(subject: 82);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82)->and($r->phoneOperator)->toBe('Tele2');
|
|
});
|
|
|
|
it('unmappable dadata region falls through to rossvyaz', function (): void {
|
|
fakeDadata(['qc' => 0, 'region' => 'Несуществующий край', 'provider' => 'МТС']);
|
|
resolverSeedRange(subject: 82);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
|
|
|
|
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
|
|
});
|
|
|
|
it('all three layers fail returns unknown with null subject_code', function (): void {
|
|
fakeDadata(['qc' => 1]); // → rossvyaz
|
|
// no phone_ranges seeded → rossvyaz miss; tag empty → null
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: ''));
|
|
|
|
expect($r->source)->toBe('unknown')->and($r->subjectCode)->toBeNull();
|
|
});
|
|
|
|
it('disabled feature flag returns tag without any dadata call', function (): void {
|
|
config(['services.dadata.enabled' => false]);
|
|
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
|
|
|
|
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('persistent idempotency: already-resolved lead skips dadata', function (): void {
|
|
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
|
|
$lead = resolverLead();
|
|
$lead->resolved_subject_code = 83;
|
|
$lead->region_source = 'dadata';
|
|
$lead->dadata_qc = 0;
|
|
$lead->phone_operator = 'МегаФон';
|
|
|
|
$r = app(LeadRegionResolver::class)->resolve($lead);
|
|
|
|
expect($r->subjectCode)->toBe(83)->and($r->source)->toBe('dadata');
|
|
Http::assertNothingSent();
|
|
});
|