787df436a3
Task 1.6 плана 2026-05-24-billing-v2-spec-c-preflight-vtb. BalanceFrozenReminderJob — окна 24-48ч (reminder) и 72-96ч (final). Throttle через balance_freeze_log markers (event_type 'reminder_sent' / 'final_sent') на 5 дней — повторов в окне не будет. Re-evaluate PreflightResult для актуального дефицита в письме (клиент мог частично пополнить — reminder покажет обновлённое число). Schedule @18:30 MSK (после основного sweep @18:00) — если sweep только что заморозил тенанта, reminder в тот же день не сработает (окно 24h+ ещё не открыто). TDD: 4 теста GREEN (reminder/final/skip-fresh/throttle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
4.2 KiB
PHP
102 lines
4.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\Billing\BalanceFrozenReminderJob;
|
|
use App\Mail\BalanceFrozenFinalMail;
|
|
use App\Mail\BalanceFrozenReminderMail;
|
|
use App\Models\PricingTier;
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
// Изоляция: liderra_testing persistent — DatabaseTransactions откатывает default-pgsql,
|
|
// SharesSupplierPdo делает pgsql_supplier общим PDO (паттерн Спека B / Task 1.4).
|
|
// Per-tenant Mail-фильтры обязательны — DemoSeeder тенанты могут попасть в sweep
|
|
// (прецедент idempotent-fixup 55a1bc05).
|
|
uses(DatabaseTransactions::class);
|
|
uses(SharesSupplierPdo::class);
|
|
|
|
beforeEach(function () {
|
|
// RLS-контекст (системный tenant 0).
|
|
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('sends reminder ~1 day after freeze', function () {
|
|
Mail::fake();
|
|
// frozen 25h назад — попадает в окно reminder (24-48h).
|
|
Carbon::setTestNow('2026-05-25 12:00:00');
|
|
$tenant = Tenant::factory()->create([
|
|
'balance_rub' => '0.00',
|
|
'frozen_by_balance_at' => Carbon::now()->subHours(25),
|
|
]);
|
|
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
|
|
|
(new BalanceFrozenReminderJob)->handle();
|
|
|
|
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
});
|
|
|
|
it('sends final ~3 days after freeze', function () {
|
|
Mail::fake();
|
|
// frozen 73h назад — попадает в окно final (72-96h).
|
|
Carbon::setTestNow('2026-05-25 12:00:00');
|
|
$tenant = Tenant::factory()->create([
|
|
'balance_rub' => '0.00',
|
|
'frozen_by_balance_at' => Carbon::now()->subHours(73),
|
|
]);
|
|
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
|
|
|
(new BalanceFrozenReminderJob)->handle();
|
|
|
|
Mail::assertQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
});
|
|
|
|
it('sends nothing for freshly frozen tenant', function () {
|
|
Mail::fake();
|
|
// frozen 2h назад — окно ещё не открылось.
|
|
Carbon::setTestNow('2026-05-25 12:00:00');
|
|
$tenant = Tenant::factory()->create([
|
|
'balance_rub' => '0.00',
|
|
'frozen_by_balance_at' => Carbon::now()->subHours(2),
|
|
]);
|
|
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
|
|
|
(new BalanceFrozenReminderJob)->handle();
|
|
|
|
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
});
|
|
|
|
it('is throttled — does not re-send reminder for same tenant in window', function () {
|
|
Mail::fake();
|
|
Carbon::setTestNow('2026-05-25 12:00:00');
|
|
$tenant = Tenant::factory()->create([
|
|
'balance_rub' => '0.00',
|
|
'frozen_by_balance_at' => Carbon::now()->subHours(25),
|
|
]);
|
|
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
|
|
|
// Первый прогон — отправляет reminder.
|
|
(new BalanceFrozenReminderJob)->handle();
|
|
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
|
|
// Сброс fake и второй прогон в том же окне — повторного письма быть не должно.
|
|
Mail::fake();
|
|
(new BalanceFrozenReminderJob)->handle();
|
|
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
|
});
|