181 lines
7.4 KiB
PHP
181 lines
7.4 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\LeadDistributor;
|
||
use App\Services\LeadRouter;
|
||
use App\Services\NotificationService;
|
||
use App\Services\RegionTagResolver;
|
||
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,
|
||
]);
|
||
linkProjectToSupplier($project, $supplierProject);
|
||
$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),
|
||
app(LeadDistributor::class),
|
||
app(RegionTagResolver::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,
|
||
]);
|
||
linkProjectToSupplier($projectA, $supplierProject);
|
||
$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,
|
||
]);
|
||
linkProjectToSupplier($projectB, $supplierProject);
|
||
$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);
|
||
});
|