Files
portal/app/tests/Feature/ProcessWebhookJobTest.php
T
Дмитрий 2d9e84ef1d phase1(antifraud): DuplicateDetector сервис (Биз-19) — антифрод-дедуп по phone в окне 24ч
Закрыт Биз-19 (§10.8.1) на код-уровне. При создании НОВОЙ сделки сервис
DuplicateDetector ищет master по (tenant_id, phone) в окне 24 ч. Если
найден — новой сделке проставляется duplicate_of_id = master.id, баланс
НЕ списывается, SupplierLeadCost НЕ создаётся. ActivityLog пишется с
context.duplicate_of = master.id.

Реализация:
  - app/app/Services/DuplicateDetector.php — отдельный сервис:
    findMaster(tenantId, phone, ?Carbon $now): ?Deal. Ищет deals с
    duplicate_of_id IS NULL и received_at >= now - 24h. Возвращает
    первую по received_at ASC или null. WINDOW_HOURS = 24 — константа.
  - App\Jobs\ProcessWebhookJob::handle() — после upsertDeal() для новой
    сделки вызывает findMaster(). Если master !== создаваемая сделка —
    markAsDuplicate(): UPDATE duplicate_of_id + ActivityLog с context.
  - DI через app(DuplicateDetector::class) внутри handle() (не в
    сигнатуре — для совместимости с прямыми вызовами из Pest без
    Bus::dispatchSync).

4 новых Pest-теста:
  - master в окне 24ч → дубль, баланс НЕ списывается
  - master старше 24ч → НЕ дубль, баланс списан дважды
  - дубли изолированы по tenant_id
  - ActivityLog для дубля содержит context.duplicate_of

Pest 41/41 зелёные за 4.1 сек. Pint + Larastan чисто.

CLAUDE.md v1.13 → v1.14. Реестр Открытые_вопросы v1.22 → v1.23.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:41:37 +03:00

380 lines
15 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;
/**
* Тесты ProcessWebhookJob — двустадийный dedup v8.6 (CTO-17).
*
* Проверяет ключевую архитектурную инвариант: один и тот же vid должен
* обновлять существующую сделку (а не создавать дубль), и баланс должен
* списываться ровно один раз. См. narrative ТЗ §5.5.
*
* NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
* trait оборачивает каждый тест в outer-транзакцию — Laravel-PG-driver
* корректно обрабатывает nested через savepoints.
*/
uses(DatabaseTransactions::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);
});
// =============================================================================
// Биз-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]);
});