6d70664904
Перенос 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>
85 lines
4.0 KiB
PHP
85 lines
4.0 KiB
PHP
<?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('приостановлен');
|
||
});
|