insertGetId([ 'imported_at' => now(), 'source_url' => 'test://rossvyaz', 'rows_inserted' => 1, 'rows_updated' => 0, 'checksum_sha256' => hash('sha256', "test-{$defCode}-{$from}-{$to}-{$subjectCode}"), 'status' => 'completed', 'completed_at' => now(), ]); DB::table('phone_ranges')->insert([ 'def_code' => $defCode, 'from_num' => $from, 'to_num' => $to, 'operator' => 'test-operator', 'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region', 'region_normalized' => null, 'subject_code' => $subjectCode, 'imported_at' => now(), 'import_id' => $importId, ]); } /** * Create a SupplierLead with a fixed phone for the cascade tests. */ function makeLeadWithPhone(string $phone, string $tag = ''): SupplierLead { $sp = SupplierProject::factory()->create(); return SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, 'phone' => $phone, 'raw_payload' => ['tag' => $tag], ]); } // --------------------------------------------------------------------------- // Seed pricing_tiers reference data (required by some full-flow tests). // --------------------------------------------------------------------------- beforeEach(function (): void { // Tenant context bypass for cross-tenant reads during seeding. DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // Reset DaData config to a known state before each test. config(['services.dadata.enabled' => false]); }); // --------------------------------------------------------------------------- // Branch 1 — feature flag off → tag-fallback // --------------------------------------------------------------------------- it('flag services.dadata.enabled=false falls through to tag-fallback (empty tag → unknown)', function (): void { // FINDING F3: plan §7 says source='tag'; actual is source='unknown' when tag is empty. // tagFallback() → tagCode=null (empty tag, RegionTagResolver returns null) → source='unknown'. config(['services.dadata.enabled' => false]); $lead = makeLeadWithPhone('79990000001', tag: ''); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() ->and($res->cacheHit)->toBeFalse(); })->group('imitation'); it('flag services.dadata.enabled=false with a valid tag resolves to source=tag', function (): void { // When tag contains a valid region name, source is 'tag' (not 'unknown'). config(['services.dadata.enabled' => false]); $lead = makeLeadWithPhone('79990000002', tag: 'Москва'); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('tag') ->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва']); })->group('imitation'); // --------------------------------------------------------------------------- // Branch 2 — qc=0 + unambiguous mapped region → source='dadata' // --------------------------------------------------------------------------- it('qc=0 + region Москва (unambiguous, maps to subject_code 82) → source=dadata', function (): void { // DERIVES code via RussianRegions — does NOT hardcode 82. $moscowCode = RussianRegions::nameToCode()['Москва']; config(['services.dadata.enabled' => true]); $fake = (new FakeDaDataPhoneClient)->stub('79990000010', qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79990000010'); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('dadata') ->and($res->subjectCode)->toBe($moscowCode) ->and($res->phoneOperator)->toBe('МТС') ->and($res->qc)->toBe(0) ->and($res->cacheHit)->toBeFalse(); })->group('imitation'); it('qc=0 + ambiguous region (Санкт-Петербург и область) falls through to rossvyaz', function (): void { // DaDataRegionMap::isAmbiguous() → true → resolver skips dadata code, goes to Россвязь. config(['services.dadata.enabled' => true]); // Seed a phone range so Россвязь lookup succeeds. // phone 79996660020: def_code=999, subscriber=6660020 $spbCode = RussianRegions::nameToCode()['Санкт-Петербург']; insertPhoneRange(defCode: 999, from: 6660000, to: 6669999, subjectCode: $spbCode); $fake = (new FakeDaDataPhoneClient)->stub('79996660020', qc: 0, region: 'Санкт-Петербург и область', provider: 'МегаФон'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79996660020'); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() ->and($res->subjectCode)->toBe($spbCode); })->group('imitation'); // --------------------------------------------------------------------------- // Branch 3 — qc=1 + phone inside seeded phone_ranges range → source='rossvyaz' // --------------------------------------------------------------------------- it('qc=1 + phone inside seeded phone_ranges range → source=rossvyaz, rossvyazMatched=true', function (): void { config(['services.dadata.enabled' => true]); // Phone 79995550011: def_code = digits[1..3] = 999, subscriber = digits[4..] = 5550011 $tyumenCode = RussianRegions::nameToCode()['Тюменская область']; // code 77 insertPhoneRange(defCode: 999, from: 5550000, to: 5559999, subjectCode: $tyumenCode); $fake = (new FakeDaDataPhoneClient)->stub('79995550011', qc: 1); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79995550011'); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() ->and($res->subjectCode)->toBe($tyumenCode); })->group('imitation'); // --------------------------------------------------------------------------- // Branch 4 — qc=2 (мусор/иностранец) → tag-fallback immediately (Россвязь skipped) // --------------------------------------------------------------------------- it('qc=2 with empty tag → source=unknown immediately (no rossvyaz)', function (): void { // FINDING F2: plan §7 says "source='tag' immediately"; actual behaviour: // resolver calls tagFallback() → empty tag → tagCode=null → source='unknown'. // The key invariant IS correct: Россвязь is NOT called for qc=2 (mусор). config(['services.dadata.enabled' => true]); $fake = (new FakeDaDataPhoneClient)->stub('79990000020', qc: 2); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79990000020', tag: ''); $res = app(LeadRegionResolver::class)->resolve($lead); // qc=2 → tagFallback → empty tag → source='unknown' (NOT 'tag') expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() ->and($res->qc)->toBe(2); })->group('imitation'); it('qc=2 with valid tag → source=tag (Россвязь skipped)', function (): void { // When qc=2 but tag resolves to a region, source='tag' (still no Россвязь). config(['services.dadata.enabled' => true]); $fake = (new FakeDaDataPhoneClient)->stub('79990000021', qc: 2); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79990000021', tag: 'Москва'); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('tag') ->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва']) ->and($res->qc)->toBe(2); })->group('imitation'); // --------------------------------------------------------------------------- // Branch 5 — DaData throws DaDataException (degradation) → falls to rossvyaz // --------------------------------------------------------------------------- it('DaDataException (degradation) falls through to rossvyaz when range seeded', function (): void { config(['services.dadata.enabled' => true]); // Phone 79997770030: def_code=999, subscriber=7770030 $voronezCode = RussianRegions::nameToCode()['Воронежская область']; // code 42 insertPhoneRange(defCode: 999, from: 7770000, to: 7779999, subjectCode: $voronezCode); $fake = (new FakeDaDataPhoneClient)->stubThrows('79997770030'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79997770030'); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('rossvyaz') ->and($res->rossvyazMatched)->toBeTrue() ->and($res->subjectCode)->toBe($voronezCode); })->group('imitation'); it('DaDataException with no rossvyaz range falls through to tag-fallback', function (): void { // No phone_ranges seeded → rossvyaz returns null → tagFallback. config(['services.dadata.enabled' => true]); $fake = (new FakeDaDataPhoneClient)->stubThrows('79998880040'); app()->instance(DaDataPhoneClient::class, $fake); $lead = makeLeadWithPhone('79998880040', tag: ''); $res = app(LeadRegionResolver::class)->resolve($lead); expect($res->source)->toBe('unknown') ->and($res->subjectCode)->toBeNull() ->and($res->rossvyazMatched)->toBeFalse(); })->group('imitation'); // --------------------------------------------------------------------------- // Branch 6 — cache: same phone resolved twice → second has cacheHit=true // --------------------------------------------------------------------------- it('cache hit: resolving the same phone twice returns cacheHit=true on second call', function (): void { config(['services.dadata.enabled' => true]); $phone = '79990000050'; $fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); $sp = SupplierProject::factory()->create(); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, 'phone' => $phone, 'raw_payload' => ['tag' => ''], ]); // First resolution — populates cache; cacheHit must be false. $res1 = app(LeadRegionResolver::class)->resolve($lead); expect($res1->cacheHit)->toBeFalse() ->and($res1->source)->toBe('dadata'); // Second lead with the SAME phone (different row, same cache key). $lead2 = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, 'phone' => $phone, 'raw_payload' => ['tag' => ''], ]); // Second resolution — must come from cache; DaData NOT called again. // The fake has the stub registered, but cacheHit=true proves cache was used. $res2 = app(LeadRegionResolver::class)->resolve($lead2); expect($res2->cacheHit)->toBeTrue() ->and($res2->source)->toBe('dadata') // source preserved from cached value ->and($res2->subjectCode)->toBe($res1->subjectCode); })->group('imitation'); // --------------------------------------------------------------------------- // Branch 7 — lead persistence: RouteSupplierLeadJob writes resolver fields to supplier_leads // --------------------------------------------------------------------------- it('RouteSupplierLeadJob persists resolved_subject_code/region_source/dadata_qc/phone_operator to supplier_lead', function (): void { // NOTE: LeadRegionResolver::resolve() does NOT itself persist to supplier_leads. // Persistence is done by RouteSupplierLeadJob::handle() (see line ~159-164). // This test exercises that full path via the job. // // We bind a fake DaData client and dispatch the job synchronously (queue=sync). // The job will also call LeadRouter and LedgerService — we seed minimal required // data (pricing_tiers + supplier_project) but expect 0 deals (no routing snapshot) // and verify only the supplier_leads column updates. config(['services.dadata.enabled' => true]); // Seed pricing tiers so LedgerService doesn't crash on boot. try { $seeder = new PricingTierSeeder; $seeder->run(); } catch (Throwable) { // Already seeded or not required for this path. } $phone = '79990000060'; $moscowCode = RussianRegions::nameToCode()['Москва']; $fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); $sp = SupplierProject::factory()->create(); $lead = SupplierLead::factory()->create([ 'supplier_project_id' => $sp->id, 'phone' => $phone, 'raw_payload' => [ 'tag' => '', 'project' => 'B1_test.example.com', 'time' => now()->getTimestamp(), 'vid' => 123456789, ], 'vid' => 123456789, 'processed_at' => null, ]); // Dispatch the job synchronously. It will run the full handle() path. // LeadRouter::matchEligibleProjects() will return empty (no snapshot seeded) → 0 deals created. // The resolver + persistence UPDATE still executes before the routing loop. RouteSupplierLeadJob::dispatchSync($lead->id); $lead->refresh(); expect($lead->resolved_subject_code)->toBe($moscowCode) ->and($lead->region_source)->toBe('dadata') ->and($lead->dadata_qc)->toBe(0) ->and($lead->phone_operator)->toBe('МТС'); })->group('imitation');