Files
portal/app/tests/Feature/Billing/BalancePreflightSweepJobTest.php
T
Дмитрий 8696b5e27f
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat/billing: F/J — единый расчёт замков + пополнение/пересчёт снимают оба замка
- D2: requiredLeadsForTomorrow переведён на полный лимит, откат share-aware R-19 [решение владельца]
- B/D3: пополнение снимает клиентскую заморозку И блоки проектов вместе, политика всё-или-ничего
- F/J/D6: вечерний пересчёт 18:00 снимает блоки проектов у незаморожённых; общий ProjectBlockReleaseService; иерархия заморозка > блок
- fix: balance_freeze_log INSERT переведён на главное соединение — межсессионный self-lock с FOR UPDATE топапа [найден живым прогоном, pg_blocking подтвердил; в тестах маскировался SharesSupplierPdo]
- spec + plan в docs/superpowers

138/138 биллинг-тестов GREEN. Pint чисто. Живьём B+F подтверждены на докалке. На прод НЕ катилось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:58:44 +03:00

222 lines
11 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\Billing\BalancePreflightSweepJob;
use App\Jobs\SyncSupplierProjectJob;
use App\Mail\BalanceFrozenMail;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
// Изоляция: liderra_testing persistent (RefreshDatabase off). DatabaseTransactions
// откатывает default-pgsql после каждого теста; SharesSupplierPdo делает pgsql_supplier
// общим PDO с pgsql — иначе job-запись balance_freeze_log (pgsql_supplier) не видит
// незакоммиченного tenant и падает на FK (паттерн Спека B / AutoPauseFlowTest).
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
// RLS-контекст (системный tenant 0) — паттерн supplier-тестов.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
PricingTier::query()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true, 'effective_from' => now()]);
});
it('freezes tenant whose balance no longer covers daily limit', function () {
Mail::fake();
// 500₽ / 50₽ = 10 лидов; проекты хотят 25 → заморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
Mail::assertQueued(BalanceFrozenMail::class);
});
it('unfreezes tenant whose balance now covers daily limit', function () {
Mail::fake();
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
});
it('is idempotent — does not re-freeze already frozen tenant', function () {
Mail::fake();
$frozenAt = now()->subDay();
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => $frozenAt]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
// Дата заморозки не перезаписана; для ЭТОГО tenant повторного письма нет.
// NB: per-tenant фильтр, т.к. liderra_testing persistent (DemoSeeder тенанты
// могут попасть в sweep и тоже получить BalanceFrozenMail — не наш ответ).
expect($tenant->fresh()->frozen_by_balance_at->timestamp)->toBe($frozenAt->timestamp);
Mail::assertNotQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
});
// Spec C extension (26.05.2026): freeze/unfreeze дёргают supplier sync в режиме 'online'.
// Привязка к существующему админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
// Online нужен сейчас для отладки (моментальный sync с поставщиком); batch будет рабочим режимом
// при росте числа клиентов (накопленные изменения уезжают одним cut-off-cron'ом в 18:00 MSK).
it('dispatches SyncSupplierProjectJob for each active project on freeze when supplier mode is online', function () {
Mail::fake();
Queue::fake();
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
// 500₽ / 50₽ = 10 лидов; 2 проекта по 15 = 30 → заморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
$p1 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
$p2 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p1->id);
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p2->id);
});
it('does NOT dispatch SyncSupplierProjectJob on freeze when supplier mode is batch', function () {
Mail::fake();
Queue::fake();
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'batch']);
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
// batch-режим: sync с поставщиком отложен до cut-off 18:00 MSK через SyncSupplierProjectsJob (множественный).
Queue::assertNotPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === Project::query()->where('tenant_id', $tenant->id)->value('id'));
});
it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online', function () {
Mail::fake();
Queue::fake();
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
$project = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id);
});
// Stage 3 / Task 3.2 — R-13 (spec §4.3.2): freeze/unfreeze sync paused_at on tenant projects.
// SupplierSnapshotGuard блокирует delete/change_source когда paused_at свежее grace-периода.
// Без этой синхронизации frozen-тенант остаётся «голым» для guard'а — клиент мог бы удалить
// проект во время заморозки и пропустить хвост слепка поставщика.
it('sets paused_at on tenant projects without paused_at when freezing', function () {
Mail::fake();
// 500₽ / 50₽ = 10 лидов; проект хочет 25 → заморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 25,
'paused_at' => null,
]);
(new BalancePreflightSweepJob)->handle();
$fresh = $project->fresh();
expect($fresh->paused_at)->not->toBeNull();
// freeze-moment должен совпадать с tenant.frozen_by_balance_at для последующего unfreeze-matcher'а.
expect($fresh->paused_at->timestamp)->toBe($tenant->fresh()->frozen_by_balance_at->timestamp);
});
it('clears paused_at on auto-paused projects when unfreezing, preserves manual pauses', function () {
Mail::fake();
// Frozen вчера в 12:00; пауза до этого момента = ручная, после = авто.
$frozenAt = now()->subDay();
$tenant = Tenant::factory()->create([
'balance_rub' => '2000.00',
'frozen_by_balance_at' => $frozenAt,
]);
// Auto-paused в момент freeze (timestamp == frozenAt → попадает в >= filter).
$autoPaused = Project::factory()->for($tenant)->create([
'is_active' => false,
'daily_limit_target' => 5,
'paused_at' => $frozenAt,
]);
// Manual-paused за 2 дня до freeze (timestamp < frozenAt → НЕ попадает в >= filter).
$manualPaused = Project::factory()->for($tenant)->create([
'is_active' => false,
'daily_limit_target' => 5,
'paused_at' => now()->subDays(2),
]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
expect($autoPaused->fresh()->paused_at)->toBeNull();
expect($manualPaused->fresh()->paused_at)->not->toBeNull();
});
// F/J (23.06.2026, спека balance-lock-unify-FJ): пересчёт 18:00 снимает блоки
// проектов у НЕзаморожённых клиентов; у заморожённых — не трогает (иерархия J:
// заморозка главнее проектного блока).
it('releases project block for active tenant when balance covers full order', function () {
Mail::fake();
// 2000₽ / 50 = 40 capacity >= 30 → клиент активен, блок проекта снять.
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 30,
'preflight_blocked_at' => now(),
]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
expect($project->fresh()->preflight_blocked_at)->toBeNull();
});
it('does NOT release project block while tenant stays frozen (insufficient balance)', function () {
Mail::fake();
// 0₽ → не хватает: клиент остаётся заморожен, блок проекта НЕ снимаем (иерархия J).
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => now()->subDay()]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 30,
'preflight_blocked_at' => now(),
]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
expect($project->fresh()->preflight_blocked_at)->not->toBeNull();
});
it('unfreezes tenant AND releases project block together when balance covers', function () {
Mail::fake();
// 2000₽ / 50 = 40 >= 30 → разморозить клиента И снять блок проекта в одном прогоне.
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 30,
'preflight_blocked_at' => now(),
]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
expect($project->fresh()->preflight_blocked_at)->toBeNull();
});