ce87936f44
При InsufficientBalanceException в LedgerService::chargeForDelivery: - DB::transaction откатывается (Deal/charge/balance не тронуты). - Outer catch в createDealCopyForProject вызывает handleInsufficientBalance: * UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS). * Email ZeroBalancePausedMail через NotificationService::notifyZeroBalancePaused. * Rate-limit 1/час/tenant через Redis SETNX (Cache::add). * Log::warning с tenant_id/project_id/balance details. - Возвращаем false (не rethrow), чтобы handle()-loop продолжал routing остальным tenant'ам. 5 тестов: project paused / email sent / rate-limit 1/h / 2nd email after 65min / sharing-flow isolation (A paused, B receives). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
7.1 KiB
PHP
174 lines
7.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\RouteSupplierLeadJob;
|
||
use App\Mail\ZeroBalancePausedMail;
|
||
use App\Models\Project;
|
||
use App\Models\Supplier;
|
||
use App\Models\SupplierLead;
|
||
use App\Models\SupplierProject;
|
||
use App\Models\Tenant;
|
||
use App\Services\Billing\LedgerService;
|
||
use App\Services\DuplicateDetector;
|
||
use App\Services\LeadRouter;
|
||
use App\Services\NotificationService;
|
||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||
use Database\Seeders\PricingTierSeeder;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
beforeEach(function () {
|
||
Mail::fake();
|
||
Cache::store('redis')->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);
|
||
});
|