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('приостановлен');
|
|||
|
|
});
|