flush(); $this->seed(PricingTierSeeder::class); DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); }); function makeFlowWithBalance(array $balance): array { $supplier = Supplier::where('code', 'b1')->first(); $supplierProject = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', ]); $tenant = Tenant::factory()->create($balance); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com', 'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true, 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); $lead = SupplierLead::factory()->create([ 'vid' => random_int(100_000_000, 999_999_999), 'phone' => '79991234567', 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], 'supplier_project_id' => $supplierProject->id, 'received_at' => now(), ]); return compact('tenant', 'project', 'lead'); } function runJob(int $leadId): void { (new RouteSupplierLeadJob($leadId))->handle( app(LeadRouter::class), app(SupplierProjectResolver::class), app(DuplicateDetector::class), app(NotificationService::class), app(LedgerService::class), ); } it('pauses project (is_active=false) when both balances empty', function () { $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); runJob($ctx['lead']->id); expect($ctx['project']->fresh()->is_active)->toBeFalse(); }); it('sends ZeroBalancePausedMail на email tenant'.chr(8217).'а', function () { $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); runJob($ctx['lead']->id); Mail::assertSent(ZeroBalancePausedMail::class, function ($mail) use ($ctx) { return $mail->hasTo($ctx['tenant']->contact_email); }); }); it('respects rate-limit 1 hour per tenant: 2 consecutive calls → 1 email only', function () { $ctx1 = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); runJob($ctx1['lead']->id); // Создаём второй lead для того же tenant'а $ctx2lead = SupplierLead::factory()->create([ 'vid' => random_int(100_000_000, 999_999_999), 'phone' => '79991234567', 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], 'supplier_project_id' => $ctx1['lead']->supplier_project_id, 'received_at' => now(), ]); runJob($ctx2lead->id); Mail::assertSent(ZeroBalancePausedMail::class, 1); }); it('sends 2nd email after 65 minutes (rate-limit expired)', function () { $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); runJob($ctx['lead']->id); Mail::assertSent(ZeroBalancePausedMail::class, 1); Carbon::setTestNow(now()->addMinutes(65)); Cache::store('redis')->flush(); // имитируем TTL expiry // Реактивируем проект (real-world: admin/cron вернул is_active=true, но клиент так и не пополнил баланс). // matchEligibleProjects() в LeadRouter фильтрует by is_active=true, иначе 2-й lead не дойдёт до handleInsufficientBalance. // NB: Eloquent $project->update(['is_active' => true]) — no-op (in-memory attr остался true с момента ::create()), // поэтому используем прямой UPDATE через pgsql_supplier (как делает handleInsufficientBalance при pause). DB::connection('pgsql_supplier') ->update('UPDATE projects SET is_active = true WHERE id = ?', [$ctx['project']->id]); $lead2 = SupplierLead::factory()->create([ 'vid' => random_int(100_000_000, 999_999_999), 'phone' => '79991234567', 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], 'supplier_project_id' => $ctx['lead']->supplier_project_id, 'received_at' => now(), ]); runJob($lead2->id); Mail::assertSent(ZeroBalancePausedMail::class, 2); }); it('sharing-flow isolation: tenant A on zero paused, tenant B with balance receives deal', function () { $supplier = Supplier::where('code', 'b1')->first(); $supplierProject = SupplierProject::factory()->create([ 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', ]); // tenantA: balance_rub > 0 (проходит WHERE EXISTS-фильтр LeadRouter), но < tier_price (500 ₽). // Поэтому projectA попадает в matched, LedgerService падает с InsufficientBalanceException → auto-pause. $tenantA = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100.00']); $tenantB = Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '0.00']); $projectA = Project::factory()->create([ 'tenant_id' => $tenantA->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com', 'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true, 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); $projectB = Project::factory()->create([ 'tenant_id' => $tenantB->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com', 'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true, 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, ]); $lead = SupplierLead::factory()->create([ 'vid' => random_int(100_000_000, 999_999_999), 'phone' => '79991234567', 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], 'supplier_project_id' => $supplierProject->id, 'received_at' => now(), ]); runJob($lead->id); expect($projectA->fresh()->is_active)->toBeFalse(); expect($projectB->fresh()->is_active)->toBeTrue(); expect((int) $tenantB->fresh()->balance_leads)->toBe(4); });