Files
portal/app/tests/Feature/Imitation/RegionResolverCascadeTest.php
T
2026-06-03 20:08:49 +03:00

374 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* Verification tests — RegionResolverCascadeTest (Task 5, Phase 1 imitation).
*
* PROVING tests against existing production code in LeadRegionResolver.
* If the resolver behaves differently than the plan describes → that is a FINDING,
* captured in test comments and the final report. Tests assert ACTUAL correct
* behaviour, not the plan's stale expectations.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 5
* Spec: §7 п.9-17
*
* Key verified facts (from reading prod code, migration DDL, README):
* - Москва = subject_code 82 (порядковый, НЕ ГИБДД), via RussianRegions::nameToCode()
* - phone_ranges schema: def_code (SMALLINT), from_num (BIGINT), to_num (BIGINT),
* operator (TEXT), region (TEXT), region_normalized (TEXT), subject_code (SMALLINT),
* imported_at (TIMESTAMPTZ), import_id (BIGINT NOT NULL FK phone_ranges_imports).
* - ImitationTestCase::seedPhoneRange() uses WRONG columns (range_from/range_to) and
* omits import_id — this is a FINDING (F1). We use insertPhoneRange() below instead.
* - RossvyazPrefixLookup parses: def_code=digits[1:4], subscriber=digits[4:] (BIGINT)
* e.g. phone=79995550011 → def_code=999, subscriber=5550011
* - LeadRegionResolver::resolve() does NOT persist to supplier_leads.
* Persistence happens in RouteSupplierLeadJob::handle() after calling resolve().
* The persistence test (branch 7) calls the job directly.
* - qc=2 (мусор) with empty tag → source='unknown' (tagCode=null), NOT 'tag'.
* The plan §7 says "source='tag' immediately" but the resolver goes tagFallback()
* which returns 'unknown' when tag is empty. This is a FINDING (F2).
* - flag off (services.dadata.enabled=false) with empty tag → source='unknown', not 'tag'.
* Same reason. FINDING (F3).
*/
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\Jobs\RouteSupplierLeadJob as RouteSupplierLeadJobAlias;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ---------------------------------------------------------------------------
// Helpers local to this test file
// ---------------------------------------------------------------------------
/**
* Insert a phone_ranges row correctly, creating a phone_ranges_imports record first.
* ImitationTestCase::seedPhoneRange() uses wrong column names (FINDING F1).
*
* @param int $defCode e.g. 999
* @param int $from e.g. 5550000
* @param int $to e.g. 5559999
* @param int $subjectCode ordinal 1..89
*/
function insertPhoneRange(int $defCode, int $from, int $to, int $subjectCode): void
{
// Ensure a phone_ranges_imports anchor row exists.
$importId = DB::table('phone_ranges_imports')->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 \Database\Seeders\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.
\App\Jobs\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');