Files
portal/app/tests/Feature/Billing/BalanceMailsQueueRestoreTest.php
T
Дмитрий 5b7bbbda50 fix(billing): письма о заморозке баланса не доходили — воркер не видел клиента под RLS
Прод-инцидент 14.07.2026. Пять писем (заморозка, напоминание, финальное,
разморозка, «проект остановлен — нет денег») уезжали в очередь с Eloquent-моделью
Tenant. SerializesModels заменяет модель на id, а воркер грузит её заново — под
ролью crm_app_user, где RLS-policy tenants_self_isolation без app.current_tenant_id
отдаёт 0 строк → ModelNotFoundException. Клиент №7 заморожен с 12.07 и не получил
ни одного письма; на проде это ломало письма о заморозке для ВСЕХ клиентов.

Письма больше не ходят в БД при отправке: несут снимок данных (без SerializesModels).

Заодно: сторож incidents:watch-failures плодил копию persistent-инцидента каждый час
(строка в failed_jobs живёт вечно, а дедуп был окном в 60 мин) — 2 залипшие ошибки
дали 31 запись за сутки и красную лампу «Очереди/джобы». Дедуп persistent теперь по
факту незакрытого инцидента, а не по возрасту последней копии.

Регрессия: BalanceMailsQueueRestoreTest (6 кейсов) + 2 теста сторожа.
Прогон: 165/165 billing+incidents, phpstan 0, pint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:55:37 +03:00

85 lines
4.0 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\Mail\BalanceFrozenFinalMail;
use App\Mail\BalanceFrozenMail;
use App\Mail\BalanceFrozenReminderMail;
use App\Mail\BalanceUnfrozenMail;
use App\Mail\ZeroBalancePausedMail;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Billing\PreflightResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
/**
* Регрессия прод-инцидента 14.07.2026: письма о заморазке баланса падали в очереди
* с ModelNotFoundException [App\Models\Tenant].
*
* Причина: письмо кладётся в очередь с Eloquent-моделью Tenant, SerializesModels
* заменяет её на ModelIdentifier(id), а воркер восстанавливает модель ЗАНОВО
* запросом в БД — под ролью crm_app_user, где RLS-policy tenants_self_isolation
* без app.current_tenant_id отдаёт 0 строк → firstOrFail() → падение. Ни одно
* письмо о заморозке до клиента не доходило.
*
* Здесь недоступность строки для воркера эмулируется удалением записи между
* постановкой в очередь и восстановлением — точный аналог «RLS не отдал строку».
* Правильное поведение: письмо несёт снимок данных и отправляется без похода в БД.
*/
it('renders queued balance mail after tenant row is invisible to the worker', function (string $mailClass, string $expectedText) {
$tenant = Tenant::factory()->create([
'organization_name' => 'ООО Заморозка',
'contact_email' => 'frozen@example.test',
]);
$result = new PreflightResult(false, 30, 20, 10);
// Что уходит в очередь (Redis) — PHP-сериализация письма.
$payload = serialize(new $mailClass($tenant, $result));
// Воркер строку не видит (на проде — RLS; здесь — записи уже нет).
DB::table('tenants')->where('id', $tenant->id)->delete();
$rendered = unserialize($payload)->render();
expect($rendered)->toContain('ООО Заморозка');
expect($rendered)->toContain($expectedText);
})->with([
'заморозка' => [BalanceFrozenMail::class, 'приостановлен'],
'напоминание' => [BalanceFrozenReminderMail::class, 'всё ещё приостановлен'],
'последнее напоминание' => [BalanceFrozenFinalMail::class, 'последнее напоминание'],
'разморозка' => [BalanceUnfrozenMail::class, 'возобновлён'],
]);
it('addresses queued balance mail to the tenant after the row is invisible', function () {
$tenant = Tenant::factory()->create(['contact_email' => 'frozen@example.test']);
$payload = serialize(new BalanceFrozenMail($tenant, new PreflightResult(false, 30, 20, 10)));
DB::table('tenants')->where('id', $tenant->id)->delete();
$envelope = unserialize($payload)->envelope();
expect($envelope->to[0]->address)->toBe('frozen@example.test');
});
it('renders queued zero-balance mail after tenant and project rows are invisible', function () {
$tenant = Tenant::factory()->create([
'organization_name' => 'ООО Ноль',
'contact_email' => 'zero@example.test',
'balance_rub' => '0.00',
]);
$project = Project::factory()->for($tenant)->create(['name' => 'Стоматология Москва']);
$payload = serialize(new ZeroBalancePausedMail($tenant, $project, 5000));
DB::table('projects')->where('id', $project->id)->delete();
DB::table('tenants')->where('id', $tenant->id)->delete();
$rendered = unserialize($payload)->render();
expect($rendered)->toContain('Стоматология Москва');
expect($rendered)->toContain('приостановлен');
});