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);
|
|||
|
|
});
|