test(imitation): scenarios G5/G6 special leads + dedup

This commit is contained in:
Дмитрий
2026-06-04 04:38:12 +03:00
parent a00c2da479
commit d5e966eebc
@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
/**
* Verification tests ScenarioG5G6_SpecialLeadsTest (Task 11, Phase 1 imitation).
*
* PROVING tests against existing production code.
* Differences vs plan FINDINGS captured in test comments.
*
* Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 11
* Spec: §6.4 G5a/b/c + G6
*
* Key verified facts (from reading prod code and RegionResolverCascadeTest findings):
*
* G5a FINDING: Plan says qc=2/7 source='tag' immediately.
* Actual: LeadRegionResolver::doResolve() line 101-103 calls tagFallback() for qc=2/7.
* tagFallback() returns source='tag' ONLY when tagCode !== null (i.e. tag is a valid region).
* Empty/null tag tagCode=null source='unknown'. Confirmed by RegionResolverCascadeTest F2.
* This test asserts REAL behaviour: empty tag 'unknown', valid tag 'tag'.
*
* G5b: DaData throws (stubThrows) OR qc=1 falls through to Россвязь.
* Source='rossvyaz' when phone_ranges row seeded and subscriber in range.
* Phone parsing: phone=7{defCode}{subscriber}, e.g. 79885550011 defCode=988, subscriber=5550011.
*
* G5c: qc=2 + no seeded range + empty tag source='unknown'.
* (Россвязь is not called at all for qc=2 it goes straight to tagFallback.)
*
* G6 (dedup the key new case in this file):
* Dedup is implemented in SupplierWebhookController::receive(), NOT in LeadInjector.
* Two HTTP POST requests with the same vid:
* First 202 + body.status='accepted' + SupplierLead created.
* Second 200 + body.status='already_processed' + body.supplier_lead_id = existing id.
* Verified from controller code lines 94-100.
* supplier_leads count for that vid stays 1.
*
* G5 tests use LeadRegionResolver directly (unit-style cascade tests).
* G6 test uses HTTP POST through the real controller route (the only dedup path).
*
* Dedup response shape (exact, from controller lines 94-100):
* HTTP 200
* { "status": "already_processed", "supplier_lead_id": <int> }
*
* First-request response shape (from controller lines 116-119):
* HTTP 202
* { "status": "accepted", "supplier_lead_id": <int> }
*/
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": <existing 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');