seed(PricingTierSeeder::class); // Global RLS bypass for seeding phase (tenant context = 0). DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // DaData config — real values irrelevant, FakeDaDataPhoneClient bypasses HTTP. config([ 'services.dadata.enabled' => true, 'services.dadata.api_key' => 'fake-key', 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); // Deterministic LeadRouter — with 1-2 candidates weightedPick always returns // all of them in SQL order (pool count ≤ cap=3), but seeded Mt19937 ensures // reproducibility if the implementation changes. $seededRouter = new LeadRouter(new Randomizer(new Mt19937(BC_SEED))); app()->instance(LeadRouter::class, $seededRouter); }); // ══════════════════════════════════════════════════════════════════════════════ // SCENARIO B — exact → all-RF cascade // ══════════════════════════════════════════════════════════════════════════════ /** * Scenario B1: Lead with a subject matching client X's exact region → goes to X (step 1). * * Setup: * - Client X: regions=[82] (Москва only) * - Client Y: regions=[] (all-RF) * - Lead resolves to Москва (code 82) via FakeDaData (qc=0, region='Москва') * * Expected: deal created for X (tenant_X), routing_step=1. * deal NOT created for Y via step-1 (Y is all-RF, not exact-82). * (Y may receive the lead if cap allows — this test has only 1 lead and cap=3 * so BOTH X and Y are eligible. Step-2 fills remaining slots.) * * Cascade logic (LeadRouter): * Phase 1 exact: X matches (82 = ANY('{82}')), Y does NOT ('{}'≠'{82}'). * Phase 2 all-RF: Y matches ('{}' = '{}'), fills remaining cap slots. * → selected = [X(step=1), Y(step=2)]. * * So: X gets routing_step=1, Y gets routing_step=2. * lead_region_resolution_log.routing_step = step of FIRST project (X→1). */ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at step 2', function (): void { // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => BC_B_DOMAIN, ]); // Client X: only Москва (exact match for subject=82). $tenantX = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectX = Project::factory()->create([ 'tenant_id' => $tenantX->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_B_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [BC_MOSCOW], ]); linkProjectToSupplier($projectX, $supplier); // Client Y: all-RF (regions='{}'). $tenantY = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectY = Project::factory()->create([ 'tenant_id' => $tenantY->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_B_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [], // all-RF ]); linkProjectToSupplier($projectY, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( project: $projectX, date: $activeDate, signalType: 'site', signalIdentifier: BC_B_DOMAIN, dailyLimit: 10, regions: '{'.BC_MOSCOW.'}', ); createRoutingSnapshotFromProject( project: $projectY, date: $activeDate, signalType: 'site', signalIdentifier: BC_B_DOMAIN, dailyLimit: 10, regions: '{}', ); // Lead resolves to Москва via FakeDaData (qc=0 → source=dadata, subject=82). $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79161000001', qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── $injector = new LeadInjector; $lead = $injector->site( domain: BC_B_DOMAIN, phone: '79161000001', tag: 'Москва', platform: 'B1', vid: 8_100_000_001, ); // ── ASSERT ────────────────────────────────────────────────────────────────── // Tenant X must have received a deal (exact Москва match, step 1). $dealsX = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantX->id) ->count(); // Tenant Y (all-RF) receives the deal at step 2 (cap allows both). $dealsY = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantY->id) ->count(); fwrite(STDOUT, PHP_EOL.'=== B1 DISTRIBUTION ==='.PHP_EOL); fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL); fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL); // Primary assertion: X gets the lead (routing_step=1 path exists). expect($dealsX)->toBe(1, 'FINDING: Client X (regions=[82]) should receive the Москва lead at step 1. '. "Got dealsX={$dealsX}. The exact-match phase (step 1) may not be working." ); // Check lead_region_resolution_log for routing_step=1. $logRow = DB::connection('pgsql_supplier') ->table('lead_region_resolution_log') ->where('supplier_lead_id', $lead->id) ->first(); fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); fwrite(STDOUT, 'resolution_log region_source: '.($logRow?->region_source ?? 'NULL').PHP_EOL); fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); fwrite(STDOUT, '=== END B1 ==='.PHP_EOL.PHP_EOL); expect($logRow)->not->toBeNull( 'FINDING: lead_region_resolution_log has no row for this lead. '. 'logRegionResolution() may have failed silently (fail-safe).' ); if ($logRow !== null) { expect((int) $logRow->routing_step)->toBe(1, 'FINDING: lead_region_resolution_log.routing_step should be 1 (first project is X at step 1). '. "Got: {$logRow->routing_step}. The log records step of first project in selected collection." ); expect((int) $logRow->subject_code_resolved)->toBe(BC_MOSCOW, 'FINDING: resolved subject_code should be 82 (Москва) from DaData qc=0. '. "Got: {$logRow->subject_code_resolved}." ); } // deals.subject_code for X's deal. $dealX = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantX->id) ->first(); if ($dealX !== null) { expect((int) $dealX->subject_code)->toBe(BC_MOSCOW, 'FINDING: deals.subject_code should be 82 (Москва) for step-1 deal. '. "Got: {$dealX->subject_code}." ); } })->group('imitation'); /** * Scenario B2: Lead with a subject nobody has exactly → goes to all-RF client (step 2). * * Setup: * - Client X: regions=[82] (Москва only) * - Client Y: regions=[] (all-RF) * - Lead resolves to code 50 (Костромская область) — no client has this exact region. * * Expected: * Phase 1 exact: nobody has code 50 → empty. * Phase 2 all-RF: Y matches → Y receives the deal at step 2. * X gets NO deal. * lead_region_resolution_log.routing_step = 2. */ it('B2: lead with foreign subject goes to all-RF client at step 2 when nobody has exact', function (): void { // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => BC_B_DOMAIN, ]); // Client X: regions=[82] (Москва) — will NOT match code 50. $tenantX = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectX = Project::factory()->create([ 'tenant_id' => $tenantX->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_B_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [BC_MOSCOW], ]); linkProjectToSupplier($projectX, $supplier); // Client Y: all-RF — will match any subject at phase 2. $tenantY = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectY = Project::factory()->create([ 'tenant_id' => $tenantY->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_B_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [], // all-RF ]); linkProjectToSupplier($projectY, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( project: $projectX, date: $activeDate, signalType: 'site', signalIdentifier: BC_B_DOMAIN, dailyLimit: 10, regions: '{'.BC_MOSCOW.'}', ); createRoutingSnapshotFromProject( project: $projectY, date: $activeDate, signalType: 'site', signalIdentifier: BC_B_DOMAIN, dailyLimit: 10, regions: '{}', ); // Lead resolves to Костромская область (code 50) — DaData qc=0, region name must // be the exact string in RussianRegions::CODE_TO_NAME[50] = 'Костромская область' // so that DaDataRegionMap::toSubjectCode() returns 50. $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79162000001', qc: 0, region: 'Костромская область', provider: 'Билайн'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── $injector = new LeadInjector; $lead = $injector->site( domain: BC_B_DOMAIN, phone: '79162000001', tag: null, platform: 'B1', vid: 8_100_000_002, ); // ── ASSERT ────────────────────────────────────────────────────────────────── $dealsX = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantX->id) ->count(); $dealsY = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantY->id) ->count(); $logRow = DB::connection('pgsql_supplier') ->table('lead_region_resolution_log') ->where('supplier_lead_id', $lead->id) ->first(); fwrite(STDOUT, PHP_EOL.'=== B2 DISTRIBUTION ==='.PHP_EOL); fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL); fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL); fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); fwrite(STDOUT, '=== END B2 ==='.PHP_EOL.PHP_EOL); // X must NOT receive the lead (code 50 is NOT in X's regions=[82]). expect($dealsX)->toBe(0, 'FINDING: Client X (regions=[82]) should NOT receive a lead with subject=50 (Костромская область). '. "Got dealsX={$dealsX}. Phase-1 exact filter may be matching wrong subjects." ); // Y must receive the lead (all-RF, step 2). expect($dealsY)->toBe(1, "FINDING: Client Y (all-RF regions='{}') should receive the lead at step 2. ". "Got dealsY={$dealsY}. Phase-2 all-RF filter may not be working." ); expect($logRow)->not->toBeNull( 'FINDING: lead_region_resolution_log has no row. logRegionResolution() may have failed.' ); if ($logRow !== null) { expect((int) $logRow->routing_step)->toBe(2, 'FINDING: routing_step should be 2 (first project in selected is Y at step 2). '. "Got: {$logRow->routing_step}." ); expect((int) $logRow->subject_code_resolved)->toBe(BC_FOREIGN, 'FINDING: resolved subject_code should be 50 (Костромская область). '. "Got: {$logRow->subject_code_resolved}." ); } })->group('imitation'); // ══════════════════════════════════════════════════════════════════════════════ // SCENARIO C — each client gets only its own region (phase-1 isolation) // ══════════════════════════════════════════════════════════════════════════════ /** * Scenario C1: Two clients with different exact regions — each lead goes to only its own. * * Setup: * - Client A: regions=[1] (Республика Адыгея) * - Client B: regions=[83] (Санкт-Петербург) * * Lead 1 resolves to subject=1 → A gets it at step 1; B does NOT. * Lead 2 resolves to subject=83 → B gets it at step 1; A does NOT. * * Phase 2 (all-RF) is empty here — neither A nor B has regions='{}', * so there are no all-RF clients. If exact match returns 1 result and cap=3, * phases 2+3 run for remaining slots. Since phase 2 (all-RF) is empty and * phase 3 (any) would match BOTH — we verify that: * - exactly 1 deal per lead is created (step-1 match); * - OR phase 3 fires and the other client ALSO gets the lead (FINDING if so). * * This test asserts the strongest useful claim: each client sees only its own * leads from step-1. Phase-3 fallback behaviour is reported as a FINDING if it * fires (because no all-RF client exists, phase 2 is empty, and phase 3 is the * "any" fallback which would give the lead to both — if that's what happens, it * means the cascade reaches phase 3 even with 1 exact match at phase 1). * * NOTE: LeadRouter cap=3 and phase-1 picks 1 project. Since combined.isNotEmpty() * after phase 1+2 → phase 3 is NOT entered (LeadRouter returns combined if * combined.isNotEmpty()). So: lead→A at step 1 only (Y is not all-RF so phase 2 * returns nothing, but combined=[A] is NOT empty → phase 3 skipped). ✓ */ it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to client B (regions=[83])', function (): void { // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => BC_C_DOMAIN, ]); // Client A: Республика Адыгея (code 1). $tenantA = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectA = Project::factory()->create([ 'tenant_id' => $tenantA->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_C_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [BC_ADYGEA], // code 1 ]); linkProjectToSupplier($projectA, $supplier); // Client B: Санкт-Петербург (code 83). $tenantB = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectB = Project::factory()->create([ 'tenant_id' => $tenantB->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_C_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [BC_SPB], // code 83 ]); linkProjectToSupplier($projectB, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( project: $projectA, date: $activeDate, signalType: 'site', signalIdentifier: BC_C_DOMAIN, dailyLimit: 10, regions: '{'.BC_ADYGEA.'}', ); createRoutingSnapshotFromProject( project: $projectB, date: $activeDate, signalType: 'site', signalIdentifier: BC_C_DOMAIN, dailyLimit: 10, regions: '{'.BC_SPB.'}', ); // FakeDaData: phone→Adygea (code 1). // RussianRegions::CODE_TO_NAME[1] = 'Республика Адыгея' $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79163000001', qc: 0, region: 'Республика Адыгея', provider: 'МегаФон'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── $injector = new LeadInjector; $leadAdygea = $injector->site( domain: BC_C_DOMAIN, phone: '79163000001', tag: null, platform: 'B1', vid: 8_100_000_010, ); // ── ASSERT ────────────────────────────────────────────────────────────────── $dealsA = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantA->id) ->count(); $dealsB = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantB->id) ->count(); $logRow = DB::connection('pgsql_supplier') ->table('lead_region_resolution_log') ->where('supplier_lead_id', $leadAdygea->id) ->first(); fwrite(STDOUT, PHP_EOL.'=== C1 DISTRIBUTION (lead→Адыгея) ==='.PHP_EOL); fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL); fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL); fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); fwrite(STDOUT, '=== END C1 ==='.PHP_EOL.PHP_EOL); // A must receive the lead. expect($dealsA)->toBe(1, 'FINDING: Client A (regions=[1], Адыгея) should receive the lead with subject=1. '. "Got dealsA={$dealsA}. Phase-1 exact match may not be working for small subject codes." ); // B must NOT receive the lead (step-1 only → combined=[A] is not empty → phase 3 skipped). expect($dealsB)->toBe(0, 'FINDING: Client B (regions=[83], СПб) should NOT receive the Адыгея lead. '. "Got dealsB={$dealsB}. ". "If >0: the cascade reached phase 3 (fallback 'any') and gave the lead to B as well. ". 'This is because phase 1 picked A (1 candidate < cap=3) and phase 2 (all-RF) was empty, '. 'so combined=[A] which is NOT empty → phase 3 is skipped per LeadRouter logic. '. 'If B got the lead, phase 3 fired — investigate LeadRouter.combined.isNotEmpty() branch.' ); if ($logRow !== null) { expect((int) $logRow->routing_step)->toBe(1, "FINDING: routing_step should be 1 (A matched exactly). Got: {$logRow->routing_step}." ); } })->group('imitation'); /** * Scenario C2: Lead with СПб subject goes only to client B, not to A. * * Mirror of C1 — proves bidirectional isolation. */ it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to client A (regions=[1])', function (): void { // ── ARRANGE ───────────────────────────────────────────────────────────────── $supplier = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => BC_C_DOMAIN, ]); $tenantA = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectA = Project::factory()->create([ 'tenant_id' => $tenantA->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_C_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [BC_ADYGEA], ]); linkProjectToSupplier($projectA, $supplier); $tenantB = Tenant::factory()->create([ 'balance_rub' => '99999.00', 'frozen_by_balance_at' => null, ]); $projectB = Project::factory()->create([ 'tenant_id' => $tenantB->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => BC_C_DOMAIN, 'daily_limit_target' => 10, 'effective_daily_limit_today' => null, 'delivered_today' => 0, 'delivered_in_month' => 0, 'delivery_days_mask' => 127, 'preflight_blocked_at' => null, 'regions' => [BC_SPB], ]); linkProjectToSupplier($projectB, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( project: $projectA, date: $activeDate, signalType: 'site', signalIdentifier: BC_C_DOMAIN, dailyLimit: 10, regions: '{'.BC_ADYGEA.'}', ); createRoutingSnapshotFromProject( project: $projectB, date: $activeDate, signalType: 'site', signalIdentifier: BC_C_DOMAIN, dailyLimit: 10, regions: '{'.BC_SPB.'}', ); // FakeDaData: phone→СПб (code 83). // RussianRegions::CODE_TO_NAME[83] = 'Санкт-Петербург' $fakeDaData = new FakeDaDataPhoneClient; $fakeDaData->stub('79164000001', qc: 0, region: 'Санкт-Петербург', provider: 'Теле2'); app()->instance(DaDataPhoneClient::class, $fakeDaData); // ── ACT ───────────────────────────────────────────────────────────────────── $injector = new LeadInjector; $leadSpb = $injector->site( domain: BC_C_DOMAIN, phone: '79164000001', tag: null, platform: 'B1', vid: 8_100_000_011, ); // ── ASSERT ────────────────────────────────────────────────────────────────── $dealsA = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantA->id) ->count(); $dealsB = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenantB->id) ->count(); $logRow = DB::connection('pgsql_supplier') ->table('lead_region_resolution_log') ->where('supplier_lead_id', $leadSpb->id) ->first(); fwrite(STDOUT, PHP_EOL.'=== C2 DISTRIBUTION (lead→СПб) ==='.PHP_EOL); fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL); fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL); fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL); fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL); fwrite(STDOUT, '=== END C2 ==='.PHP_EOL.PHP_EOL); // B must receive the lead (exact СПб match at step 1). expect($dealsB)->toBe(1, 'FINDING: Client B (regions=[83], СПб) should receive the СПб lead at step 1. '. "Got dealsB={$dealsB}." ); // A must NOT receive the lead. expect($dealsA)->toBe(0, 'FINDING: Client A (regions=[1], Адыгея) should NOT receive the СПб lead. '. "Got dealsA={$dealsA}. Phase-3 fallback may have fired — investigate." ); if ($logRow !== null) { expect((int) $logRow->routing_step)->toBe(1, "FINDING: routing_step should be 1. Got: {$logRow->routing_step}." ); expect((int) $logRow->subject_code_resolved)->toBe(BC_SPB, 'FINDING: resolved subject_code should be 83 (Санкт-Петербург). '. "Got: {$logRow->subject_code_resolved}." ); } })->group('imitation');