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(); });