$vid, 'project' => 'B2_Caranga', // префикс должен обрезаться до 'Caranga' 'tag' => 'Caranga', 'phone' => '79001234567', 'phones' => ['79001234567'], 'time' => $time ?? time(), ]; } /** * Создаёт активного поставщика и привязывает его к проекту через project_suppliers. * Используется в тестах SupplierLeadCost-ветки. */ function seedSupplierForProject(Project $project, float $costRub = 50.00): int { $supplierId = (int) DB::table('suppliers')->insertGetId([ 'code' => 'b1-test-'.Str::lower(Str::random(6)), 'name' => 'B1 Test', 'accepts_types' => '{websites,calls}', 'cost_rub' => $costRub, 'channel' => 'sites', 'quality_score' => 1.00, 'is_active' => true, 'sort_order' => 0, ]); DB::table('project_suppliers')->insert([ 'project_id' => $project->id, 'supplier_id' => $supplierId, 'is_active' => true, 'created_at' => now(), ]); return $supplierId; } test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle(); $tenant->refresh(); expect($tenant->balance_leads)->toBe(9); $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); expect($deal)->not->toBeNull(); expect($deal->source_crm_id)->toBe(100); expect($deal->phone)->toBe('79001234567'); expect($deal->status)->toBe('new'); expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан $dedup = WebhookDedupKey::query() ->where('tenant_id', $tenant->id) ->where('source_crm_id', 100) ->first(); expect($dedup)->not->toBeNull(); expect($dedup->deal_id)->toBe($deal->id); }); test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $vid = 200; // Первый webhook (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); $tenant->refresh(); expect($tenant->balance_leads)->toBe(9); $dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count(); // Второй webhook с тем же vid (но новым phone — будет UPDATE) $payload2 = makePayload(vid: $vid); $payload2['phone'] = '79009999999'; (new ProcessWebhookJob($tenant->id, $payload2))->handle(); $tenant->refresh(); expect($tenant->balance_leads)->toBe(9); // баланс не изменился expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst); $deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first(); expect($deal->phone)->toBe('79009999999'); // обновлён phone // dedup-ключ всё ещё ровно один expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1); }); test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () { $tenant = Tenant::factory()->create(['balance_leads' => 0]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle(); $tenant->refresh(); expect($tenant->balance_leads)->toBe(0); expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0); expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0); }); test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () { $tenantA = Tenant::factory()->create(['balance_leads' => 10]); $tenantB = Tenant::factory()->create(['balance_leads' => 10]); (new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle(); (new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle(); expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1); expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1); expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2); $tenantA->refresh(); $tenantB->refresh(); expect($tenantA->balance_leads)->toBe(9); expect($tenantB->balance_leads)->toBe(9); }); test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle(); (new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle(); expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1); }); test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle(); $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); DB::table('deals') ->where('id', $deal->id) ->where('received_at', $deal->received_at) ->delete(); expect(WebhookDedupKey::query() ->where('tenant_id', $tenant->id) ->where('source_crm_id', 700) ->count())->toBe(0); }); test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle(); $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); $tx = BalanceTransaction::query() ->where('tenant_id', $tenant->id) ->where('type', BalanceTransaction::TYPE_LEAD_CHARGE) ->first(); expect($tx)->not->toBeNull(); expect($tx->amount_leads)->toBe(-1); expect($tx->balance_leads_after)->toBe(9); expect($tx->related_type)->toBe(Deal::class); expect($tx->related_id)->toBe($deal->id); }); test('дубль vid НЕ создаёт BalanceTransaction', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $vid = 801; (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); expect(BalanceTransaction::query() ->where('tenant_id', $tenant->id) ->count())->toBe(1); }); test('новая сделка создаёт ActivityLog event=deal.created', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle(); $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); $log = ActivityLog::query() ->where('tenant_id', $tenant->id) ->where('deal_id', $deal->id) ->first(); expect($log)->not->toBeNull(); expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED); expect($log->user_id)->toBeNull(); expect($log->context)->toBe(['source' => 'webhook']); }); test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () { $tenant = Tenant::factory()->create(['balance_leads' => 0]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle(); $rejected = RejectedDealsLog::query() ->where('tenant_id', $tenant->id) ->first(); expect($rejected)->not->toBeNull(); expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE); expect($rejected->payload['vid'])->toBe(803); }); test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'name' => 'Caranga', // совпадает с обрезанным project из payload ]); $supplierId = seedSupplierForProject($project, costRub: 75.50); (new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle(); $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); $cost = SupplierLeadCost::query() ->where('deal_id', $deal->id) ->where('received_at', $deal->received_at) ->first(); expect($cost)->not->toBeNull(); expect($cost->supplier_id)->toBe($supplierId); expect((string) $cost->cost_rub)->toBe('75.50'); expect($cost->supplier_lead_id)->toBe(804); }); test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); (new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle(); $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); expect(SupplierLeadCost::query() ->where('deal_id', $deal->id) ->count())->toBe(0); // Сделка всё равно создаётся, баланс списан, ActivityLog есть. expect($deal)->not->toBeNull(); $tenant->refresh(); expect($tenant->balance_leads)->toBe(9); }); // ============================================================================= // Биз-19: антифрод-дедуп по phone в окне 24 ч (DuplicateDetector, §10.8.1) // ============================================================================= test('Биз-19: master в окне 24ч → дубль, баланс НЕ списывается', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $phone = '79007770001'; // Master: пришёл вчера в 12:00. $masterPayload = makePayload(vid: 901, time: now()->subHours(12)->timestamp); $masterPayload['phone'] = $phone; $masterPayload['phones'] = [$phone]; (new ProcessWebhookJob($tenant->id, $masterPayload))->handle(); $tenant->refresh(); expect($tenant->balance_leads)->toBe(9); // Дубль: пришёл сейчас, в окне 24 ч. $dupPayload = makePayload(vid: 902, time: now()->timestamp); $dupPayload['phone'] = $phone; $dupPayload['phones'] = [$phone]; (new ProcessWebhookJob($tenant->id, $dupPayload))->handle(); $master = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 901)->first(); $dup = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 902)->first(); expect($master->duplicate_of_id)->toBeNull(); expect($dup->duplicate_of_id)->toBe($master->id); $tenant->refresh(); expect($tenant->balance_leads)->toBe(9); // только master списан, дубль — нет expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(1); expect(SupplierLeadCost::query()->where('deal_id', $dup->id)->count())->toBe(0); }); test('Биз-19: master старше 24ч → НЕ дубль, баланс списывается дважды', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $phone = '79007770002'; // Master: пришёл 25 часов назад — за окном. $masterPayload = makePayload(vid: 911, time: now()->subHours(25)->timestamp); $masterPayload['phone'] = $phone; $masterPayload['phones'] = [$phone]; (new ProcessWebhookJob($tenant->id, $masterPayload))->handle(); // Новая сделка с тем же phone — master уже за окном. $newPayload = makePayload(vid: 912, time: now()->timestamp); $newPayload['phone'] = $phone; $newPayload['phones'] = [$phone]; (new ProcessWebhookJob($tenant->id, $newPayload))->handle(); $deal911 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 911)->first(); $deal912 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 912)->first(); expect($deal911->duplicate_of_id)->toBeNull(); expect($deal912->duplicate_of_id)->toBeNull(); $tenant->refresh(); expect($tenant->balance_leads)->toBe(8); // оба списаны expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(2); }); test('Биз-19: дубли изолированы по tenant_id', function () { $tenantA = Tenant::factory()->create(['balance_leads' => 10]); $tenantB = Tenant::factory()->create(['balance_leads' => 10]); $phone = '79007770003'; $payloadA = makePayload(vid: 921); $payloadA['phone'] = $phone; $payloadA['phones'] = [$phone]; (new ProcessWebhookJob($tenantA->id, $payloadA))->handle(); // Тот же phone у tenantB — НЕ должен считаться дублем. $payloadB = makePayload(vid: 922); $payloadB['phone'] = $phone; $payloadB['phones'] = [$phone]; (new ProcessWebhookJob($tenantB->id, $payloadB))->handle(); $dealA = Deal::query()->where('tenant_id', $tenantA->id)->first(); $dealB = Deal::query()->where('tenant_id', $tenantB->id)->first(); expect($dealA->duplicate_of_id)->toBeNull(); expect($dealB->duplicate_of_id)->toBeNull(); }); test('Биз-19: ActivityLog для дубля содержит context.duplicate_of', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $phone = '79007770004'; $masterPayload = makePayload(vid: 931, time: now()->subHours(2)->timestamp); $masterPayload['phone'] = $phone; $masterPayload['phones'] = [$phone]; (new ProcessWebhookJob($tenant->id, $masterPayload))->handle(); $dupPayload = makePayload(vid: 932, time: now()->timestamp); $dupPayload['phone'] = $phone; $dupPayload['phones'] = [$phone]; (new ProcessWebhookJob($tenant->id, $dupPayload))->handle(); $master = Deal::query()->where('source_crm_id', 931)->first(); $dup = Deal::query()->where('source_crm_id', 932)->first(); $masterLog = ActivityLog::query()->where('deal_id', $master->id)->first(); $dupLog = ActivityLog::query()->where('deal_id', $dup->id)->first(); expect($masterLog->context)->toBe(['source' => 'webhook']); expect($dupLog->context)->toBe(['source' => 'webhook', 'duplicate_of' => $master->id]); }); // ============================================================================= // failed() callback — финальная обработка после исчерпания ретраев // ============================================================================= test('failed() пишет упавший job в failed_webhook_jobs', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $webhookLogId = (int) DB::table('webhook_log')->insertGetId([ 'tenant_id' => $tenant->id, 'raw_payload' => json_encode(['vid' => 1001]), 'received_at' => now(), ]); $payload = makePayload(vid: 1001); $job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId); $job->failed(new RuntimeException('boom: db down')); $row = DB::table('failed_webhook_jobs') ->where('tenant_id', $tenant->id) ->first(); expect($row)->not->toBeNull(); expect($row->webhook_log_id)->toBe($webhookLogId); expect($row->exception)->toBe('boom: db down'); expect($row->retry_count)->toBe(3); expect($row->resolved_at)->toBeNull(); expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001); }); test('failed() работает БЕЗ webhookLogId (NULL ok)', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002)); $job->failed(new RuntimeException('no webhook log id')); $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first(); expect($row)->not->toBeNull(); expect($row->webhook_log_id)->toBeNull(); }); test('failed() записывает payload с UTF-8 кириллицей корректно', function () { $tenant = Tenant::factory()->create(['balance_leads' => 10]); $payload = makePayload(vid: 1003); $payload['contact_name'] = 'Дмитрий Петров'; $job = new ProcessWebhookJob($tenant->id, $payload); $job->failed(new RuntimeException('utf-8 test')); $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first(); $decoded = json_decode($row->raw_payload, true); expect($decoded['contact_name'])->toBe('Дмитрий Петров'); });