test(imitation): scenarios G5/G6 special leads + dedup
This commit is contained in:
@@ -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');
|
||||
Reference in New Issue
Block a user