Files
portal/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
T
Дмитрий 1ba1df8df1 fix(jobs): RouteSupplierLeadJob — guard на processed_at для idempotency retry (Plan 2.5 #3)
Закрывает CV.11 audit BLOCKER #3 (Plan 2/5 closure).

Проблема: $tries=3 на retry-сценарий (DB hiccup, queue worker restart) — handle()
запускался повторно без guard'а на $lead->processed_at. Второй проход создавал
ВТОРОЙ Deal в БД с тем же vid (DuplicateDetector помечал его дублем без charge,
но deal-row оставался). Также $lead->update(['deals_created_count' => $createdCount])
переписывал счётчик: первый run = 1, второй run = 0 (все дубли) → искажение метрики.

Fix: в начале handle() после findOrFail — if ($lead->processed_at !== null) return;
+ Log::info с processed_at и deals_created_count для диагностики.

TDD: новый тест 'idempotent on retry — second handle() returns early, no ghost
duplicate deals' (RouteSupplierLeadJobTest:271). Проверяет 2 последовательных
вызова runRouteJob — assertion на Deal::count, balance_leads, delivered_today,
deals_created_count все остаются на 1st-run значениях.

Pest: 548/546 passed (+1 тест от baseline 547), 1740 assertions, 17s parallel.
Larastan + Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:20:34 +03:00

385 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\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\DuplicateDetector;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
function runRouteJob(int $supplierLeadId): void
{
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
);
}
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'vashinvestor.ru',
]);
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'vashinvestor.ru',
'is_active' => true,
'delivered_today' => 0,
'delivered_in_month' => 0,
]));
}
$vid = 432176649;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => 'B1_vashinvestor.ru',
'tag' => 'tag',
'phone' => '79991234567',
'phones' => ['79991234567'],
'time' => now()->getTimestamp(),
],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(3);
expect($lead->supplier_project_id)->toBe($supplier->id);
foreach ($projects as $i => $p) {
$tenant = $tenants[$i];
$p->refresh();
expect($p->delivered_today)->toBe(1);
expect($p->delivered_in_month)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$deals = Deal::query()
->where('tenant_id', $tenant->id)
->where('source_crm_id', $vid)
->get();
expect($deals)->toHaveCount(1);
}
});
it('decrements balance_leads for each tenant by 1', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'test.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'test.ru',
'is_active' => true,
]);
$vid = 99;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
expect($tenant->fresh()->balance_leads)->toBe(99);
});
it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'test.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'test.ru',
'is_active' => true,
'delivered_today' => 0,
]);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$master = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => 999,
'project_id' => $project->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
'received_at' => now()->subHours(2),
]);
$vid = 1000;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
expect($tenant->fresh()->balance_leads)->toBe(100);
expect($project->fresh()->delivered_today)->toBe(0);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$duplicate = Deal::where('source_crm_id', $vid)->first();
expect($duplicate)->not->toBeNull();
expect($duplicate->duplicate_of_id)->toBe($master->id);
});
it('throws DomainException when payload encodes B1+SMS combo', function (): void {
$vid = 1;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_TINKOFF', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
expect(fn () => runRouteJob($lead->id))->toThrow(DomainException::class);
});
it('handles orphan supplier_project (no matching liderra-projects) — 0 deals, lead processed', function (): void {
$vid = 777;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_orphan.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(0);
expect($lead->supplier_project_id)->not->toBeNull();
});
it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'mixed.ru',
]);
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'mixed.ru',
'is_active' => true,
'delivered_today' => 0,
'delivered_in_month' => 0,
]));
}
// Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль.
$masterTenant = $tenants[0];
$masterProject = $projects[0];
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
$master = Deal::create([
'tenant_id' => $masterTenant->id,
'source_crm_id' => 555,
'project_id' => $masterProject->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
'received_at' => now()->subHours(2),
]);
$vid = 2222;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_mixed.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(2); // 2 чистых, 1 дубль не считается
// Tenant #0: deal помечен duplicate_of_id, balance НЕ списан, delivered_today = 0
expect($masterTenant->fresh()->balance_leads)->toBe(100);
expect($masterProject->fresh()->delivered_today)->toBe(0);
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
$dupDeal = Deal::query()->where('source_crm_id', $vid)->first();
expect($dupDeal->duplicate_of_id)->toBe($master->id);
// Tenant #1, #2: balance списан, delivered_today инкрементирован
foreach ([1, 2] as $i) {
$t = $tenants[$i];
$p = $projects[$i];
expect($t->fresh()->balance_leads)->toBe(99);
expect($p->fresh()->delivered_today)->toBe(1);
}
});
it('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void {
// BLOCKER #3 (CV.11 audit): RouteSupplierLeadJob::$tries = 3. На retry задачи (после
// транзиентного сбоя — DB hiccup, queue worker restart) handle() запускался ПОВТОРНО
// без guard'а на $lead->processed_at. Второй проход создавал ВТОРОЙ Deal в БД с тем
// же vid (DuplicateDetector помечал его как duplicate, без charge — но deal-row в БД
// оставался). Также $lead->update(['deals_created_count' => $createdCount]) переписывал
// счётчик: первый run = 1, второй run = 0 (все дубли) → искажение метрики.
//
// Fix: в начале handle() — if ($lead->processed_at !== null) return; — early return.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'retry-idempotent.ru',
]);
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'retry-idempotent.ru',
'is_active' => true,
'delivered_today' => 0,
]);
$vid = 7777;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => 'B1_retry-idempotent.ru',
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
// 1st run — нормальная обработка.
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(1);
expect($tenant->fresh()->balance_leads)->toBe(99);
expect($project->fresh()->delivered_today)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
// 2nd run — должен быть no-op (idempotent guard на processed_at).
runRouteJob($lead->id);
$lead->refresh();
// Лид остаётся помечен обработанным, deals_created_count НЕ сбросился.
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(1);
// НИКАКИХ дублей не появилось: balance, counter, deal-row.
expect($tenant->fresh()->balance_leads)->toBe(99);
expect($project->fresh()->delivered_today)->toBe(1);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
});
it('handles partial failure: one project throws, others continue routing', function (): void {
// Тест полагается на Tenant SoftDeletes (см. App\Models\Tenant) — soft-delete
// tenant'а в середине loop'а заставляет Tenant::firstOrFail() выкинуть
// ModelNotFoundException, что симулирует per-Project failure без мокинга.
// Если SoftDeletes когда-либо удалят с Tenant — этот тест нужно переписать
// на runtime-mock или удалить (PHPStan не пропускает $this->markTestSkipped()
// внутри Pest-closure).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'partial-failure.ru',
]);
$tenants = collect();
$projects = collect();
for ($i = 0; $i < 3; $i++) {
$t = Tenant::factory()->create(['balance_leads' => 100]);
$tenants->push($t);
$projects->push(Project::factory()->create([
'tenant_id' => $t->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'partial-failure.ru',
'is_active' => true,
'delivered_today' => 0,
]));
}
// Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт.
$tenants[1]->delete();
$vid = 3333;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_partial-failure.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(2); // tenant 0 + 2; tenant 1 упал
// Tenants 0 и 2 успешно списаны
expect($tenants[0]->fresh()->balance_leads)->toBe(99);
expect($tenants[2]->fresh()->balance_leads)->toBe(99);
});