Files
portal/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
T
Дмитрий 605c457c49 fix(jobs): RouteSupplierLeadJob — per-Project failure isolation + 2 tests
Code-review Important: один сбой Project не должен абортить routing для
остальных tenant'ов (sharing-model). + try/catch + Log::warning +
RuntimeException только если ВСЕ projects упали.

+ 2 новых теста: mixed routing (1 dup из 3 + 2 clean) и partial failure
(soft-delete tenant в середине loop'а).

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

320 lines
11 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('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);
});