Files
portal/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php
T
Дмитрий 01287d0804 fix(notifications): дайджест новых сделок не уходил на почту — список тенантов брался под RLS-ролью (0 фирм)
SendNewLeadsDigestJob брал перечень тенантов через дефолтное соединение
(crm_app_user, RLS). У очереди нет app.current_tenant_id → policy
tenants_self_isolation отдавала 0 строк, и рассылка молча превращалась
в no-op: ни одного письма о новых сделках с 19.06.2026 (проверено на бою —
0 из 58 сделок за сутки помечены отправленными).

Лечение зеркалит уже принятый фикс BalancePreflightSweepJob: перечень id
берём через pgsql_supplier (BYPASSRLS), затем per-tenant SET LOCAL внутри
digestForTenant восстанавливает контекст под RLS-ролью.

Тест: +регрессия «рассылает нескольким тенантам за прогон при системном
контексте 0»; +SharesSupplierPdo (иначе pgsql_supplier не видит
незакоммиченного тенанта). Проверено сломом выборки → 4 теста краснеют.
На бою сухим прогоном (Mail::fake): старый код 0 фирм → 0 писем,
новый 9 фирм → 1 дайджест сформирован.

Escape: владелец дал явное «коммить пуш и кати».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:45:33 +03:00

168 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\SendNewLeadsDigestJob;
use App\Mail\NewLeadNotification;
use App\Mail\NewLeadsDigestMail;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
// Перечень тенантов джоба берёт через pgsql_supplier (BYPASSRLS — иначе под RLS-ролью
// очереди список пуст, дайджест = no-op). SharesSupplierPdo делает pgsql_supplier общим
// PDO с pgsql, иначе незакоммиченный Tenant::factory() не виден и джоба шлёт 0 писем.
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
Mail::fake();
});
function digestUser(Tenant $tenant, string $email, bool $emailOn): User
{
return User::factory()->create([
'tenant_id' => $tenant->id,
'email' => $email,
'notification_preferences' => [
'new_lead' => ['email' => $emailOn, 'inapp' => true],
],
]);
}
it('шлёт одно письмо-сводку с N сделками подписанному пользователю', function () {
$tenant = Tenant::factory()->create();
digestUser($tenant, 'digest-on@example.test', true);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]);
Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(10), 'is_test' => false]);
Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(15), 'is_test' => false]);
(new SendNewLeadsDigestJob)->handle(app(NotificationService::class));
Mail::assertSent(
NewLeadsDigestMail::class,
fn (NewLeadsDigestMail $m) => $m->hasTo('digest-on@example.test') && $m->deals->count() === 3,
);
});
it('не шлёт сводку пользователю с выключенным email', function () {
$tenant = Tenant::factory()->create();
digestUser($tenant, 'digest-off@example.test', false);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]);
(new SendNewLeadsDigestJob)->handle(app(NotificationService::class));
Mail::assertNotSent(
NewLeadsDigestMail::class,
fn (NewLeadsDigestMail $m) => $m->hasTo('digest-off@example.test'),
);
});
it('не шлёт сводку, если за окно нет новых сделок', function () {
$tenant = Tenant::factory()->create();
digestUser($tenant, 'digest-old@example.test', true);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(45), 'is_test' => false]);
(new SendNewLeadsDigestJob)->handle(app(NotificationService::class));
Mail::assertNotSent(
NewLeadsDigestMail::class,
fn (NewLeadsDigestMail $m) => $m->hasTo('digest-old@example.test'),
);
});
it('повторный прогон в том же окне НЕ дублирует дайджест (N-4)', function () {
$tenant = Tenant::factory()->create();
digestUser($tenant, 'digest-dup@example.test', true);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]);
Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(10), 'is_test' => false]);
// ранбук R3b сам велит дёргать джоб вручную → два прогона в одном окне
(new SendNewLeadsDigestJob)->handle(app(NotificationService::class));
(new SendNewLeadsDigestJob)->handle(app(NotificationService::class));
// письмо-сводка ушло РОВНО один раз — уже отправленные сделки не дублируются
Mail::assertSent(NewLeadsDigestMail::class, 1);
});
it('при падении отправки НЕ помечает сделки — следующий прогон повторит (N-4)', function () {
$tenant = Tenant::factory()->create();
digestUser($tenant, 'digest-fail@example.test', true);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$deal = Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]);
// джоб прерван ДО отправки: notify бросает (анон-subclass — тип-чистый для larastan)
$throwing = new class extends NotificationService
{
public function notifyNewLeadsDigest(Tenant $tenant, Collection $deals): void
{
throw new RuntimeException('mail down');
}
};
try {
(new SendNewLeadsDigestJob)->handle($throwing);
} catch (Throwable) {
// ожидаемо — джоб упал, очередь повторит
}
expect(Cache::has('digest_sent:'.$deal->id))->toBeFalse();
// следующий прогон с рабочим сервисом — дайджест НЕ потерян, уходит
(new SendNewLeadsDigestJob)->handle(app(NotificationService::class));
Mail::assertSent(NewLeadsDigestMail::class, 1);
});
it('рассылает дайджест НЕСКОЛЬКИМ тенантам за один прогон, не завися от текущего RLS-контекста', function () {
// Регрессия на «тихий no-op»: планировщик бежит БЕЗ контекста конкретной фирмы
// (системный tenant 0). Перечень фирм джоба обязана взять сама (через pgsql_supplier),
// а не через RLS-ограниченное дефолтное соединение. Раньше список выходил пустым и
// ни одно письмо о новых сделках не уходило.
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
digestUser($tenantA, 'sweep-a@example.test', true);
digestUser($tenantB, 'sweep-b@example.test', true);
DB::statement('SET app.current_tenant_id = '.$tenantA->id);
Deal::factory()->for($tenantA)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]);
DB::statement('SET app.current_tenant_id = '.$tenantB->id);
Deal::factory()->for($tenantB)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]);
Deal::factory()->for($tenantB)->create(['received_at' => now()->subMinutes(6), 'is_test' => false]);
// Планировщик реально запускает джобу без контекста своей фирмы.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
(new SendNewLeadsDigestJob)->handle(app(NotificationService::class));
Mail::assertSent(
NewLeadsDigestMail::class,
fn (NewLeadsDigestMail $m) => $m->hasTo('sweep-a@example.test') && $m->deals->count() === 1,
);
Mail::assertSent(
NewLeadsDigestMail::class,
fn (NewLeadsDigestMail $m) => $m->hasTo('sweep-b@example.test') && $m->deals->count() === 2,
);
});
it('notifyNewLead больше не шлёт пер-лид письмо', function () {
$tenant = Tenant::factory()->create();
digestUser($tenant, 'perlead@example.test', true);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$deal = Deal::factory()->for($tenant)->create(['received_at' => now(), 'is_test' => false]);
app(NotificationService::class)->notifyNewLead($tenant, $deal);
Mail::assertNotSent(NewLeadNotification::class);
});