From d5e966eebc3a8ef4fe919daefa2ac2c40b6c84bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 4 Jun 2026 04:38:12 +0300 Subject: [PATCH] test(imitation): scenarios G5/G6 special leads + dedup --- .../ScenarioG5G6_SpecialLeadsTest.php | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php diff --git a/app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php b/app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php new file mode 100644 index 00000000..7e5a1363 --- /dev/null +++ b/app/tests/Feature/Imitation/ScenarioG5G6_SpecialLeadsTest.php @@ -0,0 +1,388 @@ + } + * + * First-request response shape (from controller lines 116-119): + * HTTP 202 + * { "status": "accepted", "supplier_lead_id": } + */ + +use App\Models\SupplierLead; +use App\Models\SupplierProject; +use App\Models\SystemSetting; +use App\Services\DaData\DaDataPhoneClient; +use App\Services\LeadRegionResolver; +use App\Support\RussianRegions; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; +use Tests\Support\Imitation\FakeDaDataPhoneClient; + +uses(DatabaseTransactions::class, SharesSupplierPdo::class)->group('imitation'); + +// --------------------------------------------------------------------------- +// Webhook secret for G6 HTTP-layer dedup tests +// --------------------------------------------------------------------------- +const G5G6_SECRET = 'g5g6-test-secret-32chars-aaaaaab'; // exactly 32 chars (controller requires strlen >= 32) + +// --------------------------------------------------------------------------- +// beforeEach — shared state for all tests in this file +// --------------------------------------------------------------------------- +beforeEach(function (): void { + // Tenant context bypass for cross-tenant reads during seeding. + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + // Default: DaData disabled (individual tests enable it as needed). + config(['services.dadata.enabled' => false]); + + // Fix for worktree-local .env placeholder APP_KEY — the default value + // 'base64:testingkeyplaceholderxxxxxxxxxxxxxxxo=' is not a valid AES-256-CBC key + // (wrong decoded length). Inject a valid 32-byte base64-encoded key so that + // Laravel's Encrypter does not throw on HTTP test requests. + // The main project's .env has a real key; phpunit.xml in this worktree has no APP_KEY. + // This fix is scoped to this file's tests only (config() changes are per-request). + if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) { + config(['app.key' => 'base64:' . base64_encode(str_repeat('a', 32))]); + app('encrypter')->__construct( + str_repeat('a', 32), + config('app.cipher', 'AES-256-CBC') + ); + } + + // Set a known webhook secret for G6 tests. + SystemSetting::query() + ->where('key', 'supplier_webhook_secret') + ->update(['value' => G5G6_SECRET]); + + // IP allowlist empty → fail-open in testing env (verifyIpAllowlist returns true + // for non-production environments when allowlist is empty — controller line 177). + SystemSetting::query() + ->where('key', 'supplier_ip_allowlist') + ->update(['value' => '[]']); + + // Seed pricing tiers (required by RouteSupplierLeadJob/LedgerService path). + try { + (new \Database\Seeders\PricingTierSeeder())->run(); + } catch (\Throwable) { + // Already seeded or not needed for this specific test. + } +}); + +// --------------------------------------------------------------------------- +// Helper: create a SupplierLead with a given phone + tag for resolver tests +// --------------------------------------------------------------------------- +function makeG5Lead(string $phone, string $tag = ''): SupplierLead +{ + $sp = SupplierProject::factory()->create(); + + return SupplierLead::factory()->create([ + 'supplier_project_id' => $sp->id, + 'phone' => $phone, + 'raw_payload' => ['tag' => $tag], + ]); +} + +// --------------------------------------------------------------------------- +// Helper: insert a phone_ranges row correctly (same as RegionResolverCascadeTest) +// --------------------------------------------------------------------------- +function insertG5PhoneRange(int $defCode, int $from, int $to, int $subjectCode): void +{ + $importId = DB::table('phone_ranges_imports')->insertGetId([ + 'imported_at' => now(), + 'source_url' => 'test://rossvyaz-g5g6', + 'rows_inserted' => 1, + 'rows_updated' => 0, + 'checksum_sha256' => hash('sha256', "g5g6-{$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, + ]); +} + +// =========================================================================== +// G5a — qc=2 (мусор) / qc=7 (иностранец) → tag-fallback immediately +// (Россвязь is NOT consulted) +// =========================================================================== + +it('G5a: qc=2 + empty tag → source=unknown (FINDING: plan says tag, actual is unknown for empty tag)', function (): void { + // FINDING: The plan §6.4 G5a says "source='tag' immediately" for qc=2/7. + // Reality: resolver calls tagFallback(); with empty tag, tagCode=null → source='unknown'. + // This was also found and documented as F2 in RegionResolverCascadeTest. + // We assert REAL behaviour. + config(['services.dadata.enabled' => true]); + + $fake = (new FakeDaDataPhoneClient())->stub('79990000201', qc: 2, region: null, provider: null); + app()->instance(DaDataPhoneClient::class, $fake); + + $lead = makeG5Lead('79990000201', tag: ''); + $res = app(LeadRegionResolver::class)->resolve($lead); + + // REAL behaviour: empty tag → tagCode=null → source='unknown', NOT 'tag' + expect($res->source)->toBe('unknown') + ->and($res->subjectCode)->toBeNull() + ->and($res->qc)->toBe(2) + ->and($res->rossvyazMatched)->toBeFalse(); // Россвязь skipped for qc=2 +})->group('imitation'); + +it('G5a: qc=2 + valid tag → source=tag (Россвязь still skipped)', function (): void { + // When qc=2 and tag resolves to a known region: source='tag'. + // Россвязь is never consulted for qc=2 (the code path jumps to tagFallback). + config(['services.dadata.enabled' => true]); + + $fake = (new FakeDaDataPhoneClient())->stub('79990000202', qc: 2, region: null, provider: null); + app()->instance(DaDataPhoneClient::class, $fake); + + $moscowCode = RussianRegions::nameToCode()['Москва']; + $lead = makeG5Lead('79990000202', tag: 'Москва'); + $res = app(LeadRegionResolver::class)->resolve($lead); + + expect($res->source)->toBe('tag') + ->and($res->subjectCode)->toBe($moscowCode) + ->and($res->qc)->toBe(2) + ->and($res->rossvyazMatched)->toBeFalse(); // Россвязь skipped for qc=2 +})->group('imitation'); + +it('G5a: qc=7 + empty tag → source=unknown (same as qc=2, Россвязь skipped)', function (): void { + // qc=7 (иностранец) behaves identically to qc=2: goes straight to tagFallback(). + config(['services.dadata.enabled' => true]); + + $fake = (new FakeDaDataPhoneClient())->stub('79990000203', qc: 7, region: null, provider: null); + app()->instance(DaDataPhoneClient::class, $fake); + + $lead = makeG5Lead('79990000203', tag: ''); + $res = app(LeadRegionResolver::class)->resolve($lead); + + expect($res->source)->toBe('unknown') + ->and($res->subjectCode)->toBeNull() + ->and($res->qc)->toBe(7) + ->and($res->rossvyazMatched)->toBeFalse(); +})->group('imitation'); + +// =========================================================================== +// G5b — DaData unavailable (stubThrows → DaDataException) OR qc=1 +// + seeded phone_ranges range → source='rossvyaz' +// =========================================================================== + +it('G5b: DaData throws DaDataException + seeded phone range → source=rossvyaz, rossvyazMatched=true', function (): void { + // DaData network failure / 5xx → resolver catches DaDataException → falls to Россвязь. + // With a seeded phone_ranges row matching the phone → source='rossvyaz'. + // + // Phone 79885550211: def_code=988, subscriber=5550211 + config(['services.dadata.enabled' => true]); + + $tyumenCode = RussianRegions::nameToCode()['Тюменская область']; // ordinal 77 + insertG5PhoneRange(defCode: 988, from: 5550000, to: 5559999, subjectCode: $tyumenCode); + + $fake = (new FakeDaDataPhoneClient())->stubThrows('79885550211'); + app()->instance(DaDataPhoneClient::class, $fake); + + $lead = makeG5Lead('79885550211', tag: ''); + $res = app(LeadRegionResolver::class)->resolve($lead); + + expect($res->source)->toBe('rossvyaz') + ->and($res->rossvyazMatched)->toBeTrue() + ->and($res->subjectCode)->toBe($tyumenCode); +})->group('imitation'); + +it('G5b: qc=1 (не уточнён) + seeded phone range → source=rossvyaz, rossvyazMatched=true', function (): void { + // qc=1 falls through the qc=0/3 block and the qc=2/7 block → arrives at Россвязь step. + // With a seeded phone range → source='rossvyaz'. + // + // Phone 79886660311: def_code=988, subscriber=6660311 + config(['services.dadata.enabled' => true]); + + $voronezCode = RussianRegions::nameToCode()['Воронежская область']; // ordinal 42 + insertG5PhoneRange(defCode: 988, from: 6660000, to: 6669999, subjectCode: $voronezCode); + + $fake = (new FakeDaDataPhoneClient())->stub('79886660311', qc: 1, region: null, provider: null); + app()->instance(DaDataPhoneClient::class, $fake); + + $lead = makeG5Lead('79886660311', tag: ''); + $res = app(LeadRegionResolver::class)->resolve($lead); + + expect($res->source)->toBe('rossvyaz') + ->and($res->rossvyazMatched)->toBeTrue() + ->and($res->subjectCode)->toBe($voronezCode); +})->group('imitation'); + +// =========================================================================== +// G5c — neither DaData (qc=2 routes to tagFallback) nor Россвязь range seeded, +// and empty tag → source='unknown' +// =========================================================================== + +it('G5c: qc=2 + no phone range seeded + empty tag → source=unknown', function (): void { + // For qc=2, Россвязь is not consulted at all (code jumps straight to tagFallback). + // Empty tag → tagCode=null → source='unknown'. + // This is a pure tagFallback outcome with no external source resolved. + config(['services.dadata.enabled' => true]); + + // No phone_ranges seeded for this phone. + $fake = (new FakeDaDataPhoneClient())->stub('79990000304', qc: 2, region: null, provider: null); + app()->instance(DaDataPhoneClient::class, $fake); + + $lead = makeG5Lead('79990000304', tag: ''); + $res = app(LeadRegionResolver::class)->resolve($lead); + + expect($res->source)->toBe('unknown') + ->and($res->subjectCode)->toBeNull() + ->and($res->rossvyazMatched)->toBeFalse(); +})->group('imitation'); + +it('G5c: DaData throws + no phone range seeded + empty tag → source=unknown', function (): void { + // DaData exception path: resolver falls to Россвязь, but no range is seeded. + // Россвязь returns null → falls to tagFallback with empty tag → source='unknown'. + config(['services.dadata.enabled' => true]); + + // No phone_ranges seeded for this phone. + $fake = (new FakeDaDataPhoneClient())->stubThrows('79990000305'); + app()->instance(DaDataPhoneClient::class, $fake); + + $lead = makeG5Lead('79990000305', tag: ''); + $res = app(LeadRegionResolver::class)->resolve($lead); + + expect($res->source)->toBe('unknown') + ->and($res->subjectCode)->toBeNull() + ->and($res->rossvyazMatched)->toBeFalse(); +})->group('imitation'); + +// =========================================================================== +// G6 — VID dedup: same vid injected twice via HTTP controller +// First → 202 + status='accepted' +// Second → 200 + status='already_processed' + same supplier_lead_id +// supplier_leads count for that vid stays exactly 1 +// =========================================================================== + +it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_processed, count stays 1', function (): void { + // Dedup is enforced in SupplierWebhookController::receive() lines 94-100. + // The UNIQUE INDEX on supplier_leads.vid prevents a second INSERT. + // The controller checks for existence BEFORE INSERT and returns early: + // if ($existing !== null) { return response()->json(['status' => 'already_processed', ...], 200); } + // + // We use the HTTP layer (not LeadInjector) because LeadInjector bypasses the controller. + // RouteSupplierLeadJob is faked to prevent actual routing which requires full snapshot setup. + + Bus::fake(); + + $vid = 987654321; // Deterministic vid, outside auto-generated ranges from LeadInjector + + $payload = [ + 'vid' => $vid, + 'project' => 'B1_g6test.example.com', + 'phone' => '79991112233', + 'time' => time(), + 'tag' => 'Москва', + ]; + + // First request → should be accepted (202). + $first = $this->postJson( + '/api/webhook/supplier/' . G5G6_SECRET, + $payload + ); + + $first->assertStatus(202); + $first->assertJson(['status' => 'accepted']); + $firstLeadId = $first->json('supplier_lead_id'); + expect($firstLeadId)->toBeInt()->toBeGreaterThan(0); + + // Verify exactly 1 SupplierLead row exists for this vid. + expect(SupplierLead::where('vid', $vid)->count())->toBe(1); + + // Second request — same vid, same payload → dedup path (200). + $second = $this->postJson( + '/api/webhook/supplier/' . G5G6_SECRET, + $payload + ); + + $second->assertStatus(200); + + // Exact response shape from controller lines 96-99: + // { "status": "already_processed", "supplier_lead_id": } + $second->assertJson([ + 'status' => 'already_processed', + 'supplier_lead_id' => $firstLeadId, + ]); + + // supplier_leads count MUST still be 1 — no second row was created. + expect(SupplierLead::where('vid', $vid)->count())->toBe(1); + + // RouteSupplierLeadJob dispatched exactly once (for the first request). + // The second request returns early before any dispatch. + Bus::assertDispatchedTimes(\App\Jobs\RouteSupplierLeadJob::class, 1); +})->group('imitation'); + +it('G6: second request returns the SAME supplier_lead_id as the first', function (): void { + // Focused assertion: the already_processed response echoes back the original id, + // not a new one. This guards against a hypothetical bug where a new row was inserted + // despite the dedup check (e.g. race) — though the UNIQUE INDEX prevents it at DB level. + + Bus::fake(); + + $vid = 987654322; // Different deterministic vid from G6 test above + + $payload = [ + 'vid' => $vid, + 'project' => 'B1_g6test.example.com', + 'phone' => '79991112244', + 'time' => time(), + ]; + + $first = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload); + $second = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload); + + $first->assertStatus(202); + $second->assertStatus(200); + + expect($second->json('supplier_lead_id'))->toBe($first->json('supplier_lead_id')); + expect(SupplierLead::where('vid', $vid)->count())->toBe(1); +})->group('imitation');