Files
portal/app/tests/Feature/Billing/BalanceMailsQueueRestoreTest.php
T
Дмитрий 6d70664904 fix(billing): перенос из main — письма о заморозке не доходили (RLS в очереди)
Перенос fc78ee1e из gitea/main в ветку стройки, чтобы выкат отсюда не вернул
сломанные письма обратно на боевой (класс ошибки 08.07 — «затёрло выкатом из
устаревшей ветки»).

Суть: 5 писем (заморозка / напоминание / финальное / разморозка / «проект
остановлен — нет денег») уезжали в очередь с моделью Tenant; воркер грузил её
заново под crm_app_user, где RLS без app.current_tenant_id отдаёт 0 строк →
ModelNotFoundException, письмо не уходило никогда. Теперь письмо несёт снимок
данных и в БД при отправке не ходит.

Плюс дедуп persistent-инцидентов сторожа — по факту незакрытого инцидента,
а не по окну 60 мин (иначе копия инцидента каждый час, бесконечно).

NB: LEFTHOOK_EXCLUDE=larastan — статанализ в рабочей копии сломан устаревшим
_ide_helper_models.php (239 ошибок в 104 ЧУЖИХ файлах, напр. фантом
«Tenant::requiredLeadsForTomorrow() не существует», хотя метод есть в
Tenant.php:93). По изменённым файлам статанализ прогнан отдельно: 0 ошибок.
Bypass согласован с владельцем. Follow-up: перегенерировать стаб (--write-mixin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:38:43 +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('приостановлен');
});