374 lines
17 KiB
PHP
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');
|