Files
portal/app/tests/Feature/Imitation/ScenarioG3_OrphanLeadTest.php
T

290 lines
14 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DaData\DaDataPhoneClient;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ConditionLevers;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/**
* Scenario G3 — «осиротевшая» заявка (orphan lead): все три фазы маршрутизации
* возвращают пустой результат — никто не eligible.
*
* Спек: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 G3
* План: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 10
*
* VERIFICATION test against existing prod routing code (LeadRouter + RouteSupplierLeadJob).
* NOT TDD — no prod code is modified. Differences vs plan → reported as FINDINGs.
*
* Сценарий:
* - Один SupplierProject (B1, site-сигнал).
* - Три клиента (проекта), каждый по-своему негоден:
* P1: лимит исчерпан (fillToLimit) — выбывает из всех трёх фаз SQL-фильтром
* delivered_today < snap.daily_limit.
* P2: заморожен (tenants.frozen_by_balance_at IS NOT NULL) — выбывает через
* tenant-фильтр LeadRouter; snapshot есть (фаза 3 всё равно не возьмёт).
* P3: баланс = 0 (tenants.balance_rub = 0) — выбывает через balance_rub > 0;
* snapshot есть (фаза 3 всё равно не возьмёт).
* - Регион лида → Москва (code 82, qc=0, via FakeDaDataPhoneClient).
* - Регион всех трёх проектов в snapshot → Москва (код 82) — это важно, чтобы
* фаза 1 могла бы найти их, если бы не другие барьеры; при этом phase 3
* (any-region) тоже не найдёт — те же tenant/limit барьеры работают во всех фазах.
*
* Ожидания (из плана §Task 10):
* - deals created = 0;
* - lead_charges = 0, balance_transactions = 0 (деньги не тронуты);
* - SupplierLead.processed_at IS NOT NULL (job завершился);
* - SupplierLead.deals_created_count = 0;
* - NO exception (job не упал);
* - Непроданный лид виден в supplier_leads.
*/
const G3_MOSCOW_CODE = 82;
const G3_SUPPLIER_DOMAIN = 'scenario-g3-orphan.ru';
const G3_SUPPLIER_PLATFORM = 'B1';
const G3_DAILY_LIMIT = 5;
const G3_LEAD_PHONE = '79161234599';
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Bind FakeDaDataPhoneClient: lead's phone resolves to Москва (qc=0, code=82).
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub(G3_LEAD_PHONE, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
});
it('orphan lead: no deals created, processed_at set, no exception, no money moved', function (): void {
// ── ARRANGE ─────────────────────────────────────────────────────────────────
// One shared SupplierProject (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => G3_SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => G3_SUPPLIER_DOMAIN,
]);
$activeDate = SnapshotForge::activeDate();
// ── Client P1: limit exhausted — fillToLimit makes delivered_today = daily_limit_target.
// queryCandidates: projects.delivered_today < snap.daily_limit → FALSE → excluded from all phases.
$tenantP1 = Tenant::factory()->create([
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP1 = Project::factory()->create([
'tenant_id' => $tenantP1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP1, $supplier);
createRoutingSnapshotFromProject(
project: $projectP1,
date: $activeDate,
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{'.G3_MOSCOW_CODE.'}',
);
// Exhaust the limit: delivered_today = G3_DAILY_LIMIT → SQL condition false in all phases.
ConditionLevers::fillToLimit($projectP1);
// ── Client P2: tenant frozen (frozen_by_balance_at IS NOT NULL).
// queryCandidates: WHERE tenants.frozen_by_balance_at IS NULL → FALSE → excluded from all phases.
$tenantP2 = Tenant::factory()->create([
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP2 = Project::factory()->create([
'tenant_id' => $tenantP2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP2, $supplier);
createRoutingSnapshotFromProject(
project: $projectP2,
date: $activeDate,
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{'.G3_MOSCOW_CODE.'}',
);
// Freeze the tenant: frozen_by_balance_at IS NOT NULL → excluded from all phases.
ConditionLevers::freeze($tenantP2);
// ── Client P3: zero balance (balance_rub = 0).
// queryCandidates: WHERE tenants.balance_rub > 0 → FALSE → excluded from all phases.
$tenantP3 = Tenant::factory()->create([
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP3 = Project::factory()->create([
'tenant_id' => $tenantP3->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP3, $supplier);
createRoutingSnapshotFromProject(
project: $projectP3,
date: $activeDate,
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{'.G3_MOSCOW_CODE.'}',
);
// Drain balance: balance_rub = 0 → balance_rub > 0 condition fails in all phases.
ConditionLevers::drainBalance($tenantP3);
// Record counts BEFORE injection to detect any pre-existing rows.
$dealsCountBefore = DB::connection('pgsql_supplier')->table('deals')->count();
$chargesCountBefore = DB::connection('pgsql_supplier')->table('lead_charges')->count();
$balanceTxCountBefore = DB::connection('pgsql_supplier')->table('balance_transactions')->count();
// ── ACT — inject one lead and run the job synchronously ─────────────────────
// We wrap in try/catch to detect exceptions — the plan says NO exception should bubble.
$thrownException = null;
$injectedLead = null;
try {
$injector = new LeadInjector;
$injectedLead = $injector->site(
domain: G3_SUPPLIER_DOMAIN,
phone: G3_LEAD_PHONE,
tag: 'Москва',
platform: G3_SUPPLIER_PLATFORM,
vid: 8_888_000_001,
);
} catch (Throwable $e) {
$thrownException = $e;
}
// ── ASSERT ──────────────────────────────────────────────────────────────────
// 1. No exception bubbled — the job must complete cleanly.
expect($thrownException)->toBeNull(
'FINDING: RouteSupplierLeadJob threw an exception for an orphan lead. '.
'Expected: no exception. Got: '.($thrownException?->getMessage() ?? 'none')
);
// 2. The SupplierLead was created and is accessible.
expect($injectedLead)->not->toBeNull(
'FINDING: LeadInjector returned null — SupplierLead was not created.'
);
// Re-fetch fresh from DB to get updated processed_at / deals_created_count.
/** @var SupplierLead $freshLead */
$freshLead = SupplierLead::find($injectedLead->id);
expect($freshLead)->not->toBeNull(
'FINDING: SupplierLead id='.$injectedLead->id.' not found in DB after injection.'
);
// 3. processed_at IS set — job stamped the lead as "processed" even though nobody received it.
// WHERE: supplier_leads table, column processed_at — this is WHERE the orphan lead "rests".
expect($freshLead->processed_at)->not->toBeNull(
'FINDING: SupplierLead.processed_at is NULL after routing with no eligible clients. '.
'Expected: RouteSupplierLeadJob always sets processed_at=now() at step 6, '.
'even when deals_created_count=0. The orphan lead should rest in supplier_leads '.
'with processed_at set (idempotency guard).'
);
// 4. deals_created_count = 0 — no deals were created.
expect((int) $freshLead->deals_created_count)->toBe(0,
'FINDING: SupplierLead.deals_created_count expected 0 but got '.
$freshLead->deals_created_count.'. '.
'No eligible project was found — no deal should have been created.'
);
// 5. No deals created across all three tenants.
$newDealsCount = DB::connection('pgsql_supplier')->table('deals')->count() - $dealsCountBefore;
expect($newDealsCount)->toBe(0,
'FINDING: '.$newDealsCount.' deal(s) were created despite all clients being ineligible. '.
'P1(limit-exhausted), P2(frozen), P3(zero-balance) should all fail LeadRouter SQL filter.'
);
// 6. No lead_charges created — no money moved.
$newChargesCount = DB::connection('pgsql_supplier')->table('lead_charges')->count() - $chargesCountBefore;
expect($newChargesCount)->toBe(0,
'FINDING: '.$newChargesCount.' lead_charge row(s) created despite no eligible clients. '.
'LedgerService::chargeForDelivery should not have been called.'
);
// 7. No balance_transactions created — balances untouched.
$newBalanceTxCount = DB::connection('pgsql_supplier')->table('balance_transactions')->count() - $balanceTxCountBefore;
expect($newBalanceTxCount)->toBe(0,
'FINDING: '.$newBalanceTxCount.' balance_transaction(s) created despite no eligible clients.'
);
// 8. The orphan lead is visible/recorded — WHERE it rests.
// It lives in `supplier_leads` with processed_at IS NOT NULL and deals_created_count = 0.
$orphanCount = DB::table('supplier_leads')
->where('id', $freshLead->id)
->whereNotNull('processed_at')
->where('deals_created_count', 0)
->count();
expect($orphanCount)->toBe(1,
'FINDING: Orphan lead not found in supplier_leads with processed_at IS NOT NULL and '.
'deals_created_count=0. The unsold lead should rest in supplier_leads, identifiable by '.
'processed_at IS NOT NULL + deals_created_count = 0 (no error column set).'
);
// ── REPORT ──────────────────────────────────────────────────────────────────
fwrite(STDOUT, PHP_EOL.'=== SCENARIO G3 ORPHAN LEAD REPORT ==='.PHP_EOL);
fwrite(STDOUT, 'SupplierLead id: '.$freshLead->id.PHP_EOL);
fwrite(STDOUT, 'processed_at: '.($freshLead->processed_at?->toIso8601String() ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'deals_created_count: '.$freshLead->deals_created_count.PHP_EOL);
fwrite(STDOUT, 'error: '.($freshLead->error ?? 'NULL (no error)').PHP_EOL);
fwrite(STDOUT, 'deals created (new): '.$newDealsCount.PHP_EOL);
fwrite(STDOUT, 'lead_charges (new): '.$newChargesCount.PHP_EOL);
fwrite(STDOUT, 'balance_transactions(new):'.$newBalanceTxCount.PHP_EOL);
fwrite(STDOUT, PHP_EOL.'WHERE the orphan lead rests:'.PHP_EOL);
fwrite(STDOUT, ' Table: supplier_leads'.PHP_EOL);
fwrite(STDOUT, ' Filter: processed_at IS NOT NULL AND deals_created_count = 0'.PHP_EOL);
fwrite(STDOUT, ' Note: error column is NULL (clean completion, not a failure).'.PHP_EOL);
fwrite(STDOUT, ' Note: NO entry in failed_webhook_jobs (job::failed() not called).'.PHP_EOL);
fwrite(STDOUT, '=== END G3 REPORT ==='.PHP_EOL.PHP_EOL);
})->group('imitation');