Files
portal/app/tests/Feature/Supplier/CsvWebhookRaceTest.php
T
Дмитрий e8782c47b3 test(supplier): assert webhook-after-csv-recovered merges into existing deal (failing)
Reproduces 37 duplicate deals observed on prod 2026-05-25 for tenant client1.
After Spec B Phase 1 (commit ccfecd5e) removed DuplicateDetector, the race
between CsvReconcileJob (creates SupplierLead vid=null) and later webhook
retry (vid=int) results in two separate Deals because supplier_lead_deliveries
locks on supplier_lead_id (which differs between csv-recovery and webhook),
not on (phone, project_id).

Failing now — implementation comes in next commit.
2026-05-25 17:54:20 +03:00

292 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Phase 2 — webhook ↔ CSV-recovered idempotency.
*
* Сценарий (наблюдался на prod 2026-05-25, 37 дублей tenant client1):
* 1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
* 2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
* по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
* source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
* 3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
* → биллинг списывает второй раз.
*
* Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
*/
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
// Shared supplier_project для всех тестов (B1, site, domain race-csv.ru).
$this->sp = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'race-csv.ru',
]);
$this->tenant = Tenant::factory()->create([
'balance_rub' => '10000.00',
'delivered_in_month' => 0,
]);
$this->project = Project::factory()->create([
'tenant_id' => $this->tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'race-csv.ru',
'supplier_b1_project_id' => $this->sp->id,
'is_active' => true,
'daily_limit_target' => 100,
'effective_daily_limit_today' => 100,
'delivered_today' => 0,
'delivery_days_mask' => 127,
'region_mask' => 255,
]);
linkProjectToSupplier($this->project, $this->sp);
});
/**
* Dispatch helper — mirrors runRouteJob() / dispatchJob() from other test files.
*/
function runRaceJob(int $supplierLeadId): void
{
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
// ---------------------------------------------------------------------------
// Test 1 — Main bug reproduction: CSV-recovery followed by webhook retry
// ДОЛЖЕН дать 1 deal + 1 charge (сейчас даёт 2+2 → FAILING).
// ---------------------------------------------------------------------------
it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function (): void {
$phone = '79991000001';
// ── Step 1: CSV-recovered SupplierLead (vid=null, source='csv_recovery') ──
// Это то, что CsvReconcileJob создаёт: звонок найден в CSV поставщика,
// но настоящего webhook_log'а нет → вид неизвестен (vid=null).
$csvLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => null,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subHour()->getTimestamp(),
],
'received_at' => now()->subHour(),
'recovered_from_csv_at' => now()->subHour(),
'source' => 'csv_recovery',
'processed_at' => null,
]);
// RouteSupplierLeadJob обрабатывает CSV-recovered лид → создаёт Deal с source_crm_id=NULL.
runRaceJob($csvLead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
$csvDeal = Deal::where('phone', $phone)->first();
expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal');
expect($csvDeal->source_crm_id)->toBeNull('CSV-recovered deal должен иметь source_crm_id=NULL');
$chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
expect($chargesAfterCsv)->toBe(1, 'После CSV-recovery должен быть ровно 1 LeadCharge');
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
// ── Step 2: поставщик ретраит webhook 15 мин спустя с настоящим vid ──
// Это то, что создаёт дубль на проде: новый SupplierLead с vid != null,
// phone + project те же → RouteSupplierLeadJob создаёт ВТОРОЙ Deal.
$webhookLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 1672819986,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 1672819986,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subMinutes(15)->getTimestamp(),
],
'received_at' => now()->subMinutes(15),
'source' => 'webhook',
'processed_at' => null,
]);
runRaceJob($webhookLead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
// ── Assertions ──
// Assertion 1: по-прежнему ОДИН deal, но source_crm_id теперь заполнен.
$deals = Deal::where('phone', $phone)->get();
expect($deals)->toHaveCount(1, 'Phase 2: webhook после CSV-recovery должен ОБНОВИТЬ существующий deal, а не создать второй');
expect($deals->first()->source_crm_id)->toBe(1672819986, 'source_crm_id должен быть обновлён от webhook vid');
// Assertion 2: НЕТ второго LeadCharge — биллинг не списывается дважды.
$chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
expect($chargesAfterWebhook)->toBe(1, 'Phase 2: второй LeadCharge создан не должен быть');
// Assertion 3: баланс НЕ списан второй раз.
$balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
expect($balanceAfterWebhook)->toBe($balanceAfterCsv, 'Phase 2: баланс после webhook не должен уменьшиться');
// Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
// привязанных к ОДНОМУ deal_id.
$deliveries = DB::table('supplier_lead_deliveries')
->where('deal_id', $csvDeal->id)
->get();
expect($deliveries)->toHaveCount(2, 'Оба SupplierLead (csv + webhook) должны быть в supplier_lead_deliveries');
$deliveredLeadIds = $deliveries->pluck('supplier_lead_id')->sort()->values()->all();
expect($deliveredLeadIds)->toContain($csvLead->id);
expect($deliveredLeadIds)->toContain($webhookLead->id);
});
// ---------------------------------------------------------------------------
// Test 2 — Spec B regression: два webhook с РАЗНЫМИ vid → два deal (by-design).
// Наш Phase 2 fix НЕ должен блокировать это.
// ---------------------------------------------------------------------------
it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function (): void {
$phone = '79991000002';
// Первый webhook, vid=100.
$lead1 = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 100,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 100,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subHour()->getTimestamp(),
],
'received_at' => now()->subHour(),
'source' => 'webhook',
'processed_at' => null,
]);
runRaceJob($lead1->id);
// Второй webhook, vid=200 (другой лид поставщика, тот же телефон+проект).
$lead2 = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 200,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 200,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subMinutes(30)->getTimestamp(),
],
'received_at' => now()->subMinutes(30),
'source' => 'webhook',
'processed_at' => null,
]);
runRaceJob($lead2->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
// Spec B: оба webhook'а имеют source_crm_id != null.
// Условие merge (source_crm_id IS NULL) не срабатывает → два deal,
// два LeadCharge. Spec B Phase 1 (commit ccfecd5e) за повторы поставщика берём.
$deals = Deal::where('phone', $phone)->get();
expect($deals)->toHaveCount(2, 'Два webhook с разными vid должны создавать два deal (Spec B)');
$sourceCrmIds = $deals->pluck('source_crm_id')->sort()->values()->all();
expect($sourceCrmIds)->toContain(100);
expect($sourceCrmIds)->toContain(200);
expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
});
// ---------------------------------------------------------------------------
// Test 3 — Boundary: CSV-recovered deal старше 24h НЕ мержится с новым webhook.
// Окно merge — 24h. Старый лид не считается «активным» duplicate.
// ---------------------------------------------------------------------------
it('csv-recovered deal older than 24h is NOT merged with new webhook', function (): void {
$phone = '79991000003';
// CSV-recovered SupplierLead, обработанный 2 дня назад.
$csvLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => null,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->subDays(2)->getTimestamp(),
],
'received_at' => now()->subDays(2),
'recovered_from_csv_at' => now()->subDays(2),
'source' => 'csv_recovery',
'processed_at' => null,
]);
runRaceJob($csvLead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
$csvDeal = Deal::where('phone', $phone)->first();
expect($csvDeal)->not->toBeNull('CSV-recovered deal должен существовать');
// Сбросим processed_at у tenant-level проекта: delivered_today накопился,
// нужно сбросить счётчик чтобы второй deal тоже прошёл лимит.
$this->project->update(['delivered_today' => 0]);
// Webhook приходит сейчас — deal CSV-recovery старше 24h → не мержится.
$webhookLead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => $phone,
'vid' => 999,
'supplier_project_id' => $this->sp->id,
'raw_payload' => [
'vid' => 999,
'project' => 'B1_race-csv.ru',
'phone' => $phone,
'time' => now()->getTimestamp(),
],
'received_at' => now(),
'source' => 'webhook',
'processed_at' => null,
]);
runRaceJob($webhookLead->id);
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
// Два deal: старый CSV-recovered (2 дня назад) + новый от webhook.
// Merge НЕ происходит — CSV-recovered вне 24h окна.
$deals = Deal::where('phone', $phone)->get();
expect($deals)->toHaveCount(2, 'CSV-recovered deal старше 24h — merge не происходит, создаётся новый deal от webhook');
});