Files
portal/app/tests/Feature/ProcessWebhookJobTest.php
T
2026-05-23 20:44:53 +03:00

363 lines
14 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\ProcessWebhookJob;
use App\Models\ActivityLog;
use App\Models\BalanceTransaction;
use App\Models\Deal;
use App\Models\Project;
use App\Models\RejectedDealsLog;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\WebhookDedupKey;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
/**
* Тесты ProcessWebhookJob — двустадийный dedup v8.6 (CTO-17).
*
* Проверяет ключевую архитектурную инвариант: один и тот же vid должен
* обновлять существующую сделку (а не создавать дубль), и баланс должен
* списываться ровно один раз. См. narrative ТЗ §5.5.
*
* NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
* trait оборачивает каждый тест в outer-транзакцию — Laravel-PG-driver
* корректно обрабатывает nested через savepoints.
*
* SharesSupplierPdo: failed() now inserts via pgsql_supplier (BYPASSRLS) —
* share PDO so DatabaseTransactions cross-connection visibility works on dev.
*/
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function makePayload(int $vid = 432176649, ?int $time = null): array
{
return [
'vid' => $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);
});
// =============================================================================
// Spec B: no phone dedup — supplier owns dedup, Лидерра charges everything delivered
// =============================================================================
test('charges both leads with same phone but different vid (no phone dedup, Spec B)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 5]);
$phone = '79007770010';
// First webhook — distinct vid
$payload1 = makePayload(vid: 951);
$payload1['phone'] = $phone;
$payload1['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $payload1))->handle();
// Second webhook — same phone, different vid
$payload2 = makePayload(vid: 952);
$payload2['phone'] = $phone;
$payload2['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $payload2))->handle();
$tenant->refresh();
// Both charged — balance_leads decremented twice.
expect($tenant->balance_leads)->toBe(3);
// Two distinct deals exist for this tenant.
$deals = Deal::query()->where('tenant_id', $tenant->id)->get();
expect($deals)->toHaveCount(2);
// Neither deal has duplicate_of_id set.
foreach ($deals as $deal) {
expect($deal->duplicate_of_id)->toBeNull();
}
});
// =============================================================================
// 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('Дмитрий Петров');
});