Files
portal/app/tests/Feature/Supplier/AutoPauseFlowTest.php
T
Дмитрий 2ec70b338f
Accessibility (Pa11y live) / a11y (push) Has been cancelled
test: оздоровление тест-стенда — изоляция протекателей плюс фикстуры, партиции, видимость supplier-коннекта
Закрыто 36 из 55 пре-существующих падений backend-набора (55 to 19), всё тест-сторона,
код продукта не тронут. Группы:
- incident-показ/РКН: добавлен SharesSupplierPdo + синхрон уровня транзакции в трейте
  (вложенный transaction на общем PDO теперь делает SAVEPOINT, не повторный BEGIN).
- auto-pause и lead-delivery: тесты создают project_routing_snapshots, от которого
  зависит выбор кандидатов в LeadRouter (slepok-инвариант).
- изоляция 16 протекающих тестов: добавлен DatabaseTransactions (где нужно плюс
  SharesSupplierPdo) — перестали оставлять committed-строки, отравлявшие глобально
  сканирующие тесты (snapshot, verify-audit, size-N).
- partition time-bombs: ensureRange месячных партиций для тестов на дату 2026-05.
- устаревшие ассерты: SchemaDelta метрики v8.35 to v8.52, ProjectsStore телефон 8 to 7
  нормализуется, incidents-watch фильтр активного admin, register captcha_token,
  impersonation активный юзер тенанта, activity_log.deal_id, ProjectUpdateDedup пауза.

Остаток 19 (отдельно): verify-audit-chains и size-N (протекатели audit-строк),
webhook B-префикс (решение владельца), пара env/каскадных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:39:51 +03:00

184 lines
7.9 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\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\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);
// LeadRouter::matchEligibleProjects() выбирает кандидатов ТОЛЬКО из project_routing_snapshots
// (slepok-инвариант). Без снапшота на сегодня кандидатов 0 → джоб не доходит до auto-pause.
createRoutingSnapshotFromProject($project, signalType: 'site', signalIdentifier: 'example.com');
$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(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_rub' => '100000.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);
createRoutingSnapshotFromProject($projectA, signalType: 'site', signalIdentifier: 'example.com');
$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);
createRoutingSnapshotFromProject($projectB, signalType: 'site', signalIdentifier: 'example.com');
$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((string) $tenantB->fresh()->balance_rub)->toBe('99500.00');
});