Files
portal/app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
T
Дмитрий c1ae195141 fix(jobs): RouteSupplierLeadJob — lockForUpdate Project + recheck delivered_today (Plan 2.5 #2)
Закрывает CV.11 audit BLOCKER #2 (Plan 2/5 closure).

Проблема: matchEligibleProjects делал SELECT delivered_today < limit БЕЗ lockForUpdate.
Между snapshot'ом и createDealCopyForProject (который инкрементит счётчик) — окно
для concurrent webhook'а:
  worker A видит delivered_today=9, limit=10 → OK; createDealCopyForProject → 10.
  worker B параллельно видит то же 9 → OK; createDealCopyForProject → 11. OVERCOMMIT.
Лимит daily_limit_target нарушен, баланс tenant'а списан дважды.

Fix: внутри createDealCopyForProject (после lockForUpdate Tenant) — lockForUpdate(Project)
+ recheck delivered_today >= COALESCE(effective_daily_limit_today, daily_limit_target).
Если уже at-limit под блокировкой → return false без charge / counter / deal-row +
Log::info('supplier_lead.project_at_limit_skipped') для observability.

TDD: новый тест 'rejects deal copy if delivered_today >= limit at lock time' симулирует
race через Mockery — мокнутый LeadRouter возвращает project уже at-limit
(delivered_today=1, daily_limit_target=1), как будто matchEligibleProjects делал SELECT
когда delivered_today=0. Assert: deal НЕ создаётся, counter не растёт, balance не списан.

Pest: 549/547 passed (+2 от baseline 547), 1745 assertions, 19s parallel.
Larastan + Pint: passed.

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

460 lines
18 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\Collection;
use Illuminate\Support\Facades\DB;
use Mockery as M;
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);
});
it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2 race recheck)', function (): void {
// BLOCKER #2 (CV.11 audit): matchEligibleProjects делает SELECT delivered_today < limit
// БЕЗ lockForUpdate. Между snapshot SELECT и createDealCopyForProject (которое
// инкрементит) — окно для concurrent webhook'а:
// worker A видит delivered_today=9, limit=10 → OK; createDealCopyForProject → 10.
// worker B параллельно видит то же 9 → OK; createDealCopyForProject → 11. OVERCOMMIT.
//
// Симуляция: project уже at-limit (delivered_today=1, daily_limit_target=1) к
// моменту createDealCopyForProject — мокнутый LeadRouter возвращает его как eligible
// (так, будто matchEligibleProjects делал SELECT когда delivered_today=0).
//
// Fix #2: внутри createDealCopyForProject под lockForUpdate(Project) — recheck
// delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target).
// Если уже at-limit → return false без charge / counter / deal-row.
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'race-recheck.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' => 'race-recheck.ru',
'is_active' => true,
'daily_limit_target' => 1,
'effective_daily_limit_today' => null, // COALESCE → daily_limit_target=1
'delivered_today' => 1, // ALREADY AT LIMIT (race-window simulation)
'delivered_in_month' => 5,
]);
$vid = 8888;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => [
'vid' => $vid,
'project' => 'B1_race-recheck.ru',
'phone' => '79991234567',
'time' => now()->getTimestamp(),
],
]);
// Подсунуть LeadRouter mock, который игнорирует filter и возвращает project,
// как будто SELECT'нул его при snapshot delivered_today=0.
$routerMock = M::mock(LeadRouter::class);
$routerMock->shouldReceive('matchEligibleProjects')
->andReturn(new Collection([$project]));
app()->instance(LeadRouter::class, $routerMock);
runRouteJob($lead->id);
$lead->refresh();
// После fix #2: deal НЕ создан (recheck под lock увидел limit) → 0 deals.
expect($lead->deals_created_count)->toBe(0);
// delivered_today остался 1 (НЕ инкрементнулся до 2).
expect($project->fresh()->delivered_today)->toBe(1);
// delivered_in_month НЕ инкрементнулся.
expect($project->fresh()->delivered_in_month)->toBe(5);
// balance_leads НЕ списан.
expect($tenant->fresh()->balance_leads)->toBe(100);
// Deal-row не создался.
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0);
});