Files
portal/app/tests/Feature/Notifications/NewLeadNotificationTest.php
T
Дмитрий 4c33323f0e phase2(notifications-stage6): low_balance + zero_balance + topup + invoice email/inapp
P0 этап 6 — 4 оставшихся email-события. Авто-план P0 (6 этапов) закрыт
полностью: все 8 schema-default событий имеют рабочую интеграцию
(new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid +
заглушки для new_device_login/marketing).

Backend:
- 4 новых Mailable: LowBalanceNotification (threshold), ZeroBalanceNotification,
  TopupSuccessNotification (amountRub, amountLeads?), InvoicePaidNotification
  (amountRub, invoiceNumber?, tariffName?).
- 4 blade-шаблона в emails/ (Forest-палитра, таблицы balance/amount/invoice).
- NotificationService +4 методов: notifyLowBalance / notifyZeroBalance /
  notifyTopupSuccess / notifyInvoicePaid. Все шлют email + inapp по prefs.

Интеграция в ProcessWebhookJob:
- chargeNewLead после lead_charge: notifyLowBalance при пересечении порога
  сверху-вниз (balance_after <= threshold AND (balance_after+1) > threshold).
  Иначе спам при каждом lead_charge при balance < threshold.
- logRejection(zero_balance): notifyZeroBalance ТОЛЬКО если в последний час
  не было другого RejectedDealsLog с тем же reason (anti-spam 1 email/час).
  Защита от self-just-inserted через id!= (timestamp-сравнение ненадёжно
  из-за PG microsecond precision).
- topup_success / invoice_paid — service-методы готовы, integration после
  появления endpoints для пополнения (ЮKassa-webhook) и оплаты тарифа.
- lowBalanceThreshold() читает system_settings.low_balance_threshold_leads
  (default 10, schema seed).

Pest +12 в BalanceNotificationsTest (359/359 за 41.37 сек, 1233 assertions):
- low_balance: пересечение порога / уже < threshold / > threshold /
  prefs.email=false (только inapp).
- zero_balance: первое отклонение / 2-е в час не дублирует / >1ч снова шлёт.
- topup_success / invoice_paid: notify создаёт email+inapp / prefs=email:false.
- balance events изолированы между tenants.

NewLeadNotificationTest: «balance=0 не шлёт» обновлён —
Mail::assertNotSent(NewLeadNotification) вместо Mail::assertNothingSent
(ZeroBalanceNotification теперь шлётся при balance=0 — новое поведение).

PHPStan baseline регенерирован.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:51:32 +03:00

200 lines
8.2 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\Jobs\ProcessWebhookJob;
use App\Mail\NewLeadNotification;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
/**
* Тесты email-уведомления о новом лиде (ТЗ §18.5, событие new_lead).
*
* Проверяет интеграцию NotificationService → ProcessWebhookJob: после успешного
* chargeNewLead все активные user'ы тенанта с notification_preferences.new_lead.email=true
* получают NewLeadNotification. Mail::fake() перехватывает реальную отправку.
*
* Schema-default: notification_preferences.new_lead.email=false → по умолчанию
* никто не получает emails. Тесты явно ставят email=true для нужных user'ов.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
Mail::fake();
});
/**
* @return array<string, mixed>
*/
function newLeadPayload(int $vid = 200, ?int $time = null): array
{
return [
'vid' => $vid,
'project' => 'B2_Caranga',
'tag' => 'Caranga',
'phone' => '79001234567',
'phones' => ['79001234567'],
'time' => $time ?? time(),
];
}
/**
* @param array<string, mixed> $newLeadPrefs
*/
function makeUserWithPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User
{
return User::factory()->create([
'tenant_id' => $tenant->id,
'email' => $email,
'notification_preferences' => [
'new_lead' => $newLeadPrefs,
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
'low_balance' => ['email' => true],
'zero_balance' => ['email' => true],
'topup_success' => ['email' => true],
'invoice_paid' => ['email' => true],
'new_device_login' => ['email' => true],
'marketing' => ['email' => false],
],
]);
}
test('webhook: NewLeadNotification отправляется user\'ам с email=true', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$userOn = makeUserWithPrefs($tenant, 'on@example.ru', ['inapp' => true, 'push' => true, 'email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail) use ($userOn): bool {
return $mail->manager->id === $userOn->id
&& $mail->hasTo('on@example.ru');
});
});
test('webhook: user с email=false НЕ получает', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'off@example.ru', ['inapp' => true, 'push' => true, 'email' => false]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: schema-default не шлёт (new_lead.email=false по дефолту)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
// Не передаём notification_preferences — берётся schema DEFAULT.
User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'default@example.ru',
]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: рассылается всем активным user\'ам с email=true', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'a@example.ru', ['email' => true]);
makeUserWithPrefs($tenant, 'b@example.ru', ['email' => true]);
makeUserWithPrefs($tenant, 'c@example.ru', ['email' => false]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, 2);
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('c@example.ru'));
});
test('webhook: inactive user с email=true НЕ получает (is_active=false)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
User::factory()->inactive()->create([
'tenant_id' => $tenant->id,
'email' => 'inactive@example.ru',
'notification_preferences' => [
'new_lead' => ['email' => true],
],
]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: soft-deleted user НЕ получает', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$user = makeUserWithPrefs($tenant, 'deleted@example.ru', ['email' => true]);
$user->delete();
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertNothingSent();
});
test('webhook: user другого тенанта НЕ получает (изоляция)', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenantA, 'a@example.ru', ['email' => true]);
makeUserWithPrefs($tenantB, 'b@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenantA->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
});
test('webhook: дубль-сделка (Биз-19) НЕ шлёт повторное уведомление', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
// Первая сделка — master.
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 1)))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
// Вторая сделка с тем же phone в окне 24 ч — дубль, баланс НЕ списывается,
// chargeNewLead НЕ вызывается, уведомление НЕ шлётся.
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 2)))->handle();
Mail::assertSent(NewLeadNotification::class, 1); // всё ещё одно
});
test('webhook: повторный vid (idempotent UPDATE) НЕ шлёт повторное уведомление', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
// Повторный webhook с тем же vid — UPDATE, не INSERT. wasRecentlyCreated=false → return.
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
Mail::assertSent(NewLeadNotification::class, 1);
});
test('webhook: balance=0 (RejectedDealsLog) НЕ шлёт NewLeadNotification', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
// chargeNewLead НЕ вызывается при balance=0 — NewLeadNotification не шлётся.
// (ZeroBalanceNotification ШЛЁТСЯ — это покрывается отдельным тестом.)
Mail::assertNotSent(NewLeadNotification::class);
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
});
test('NewLeadNotification: subject содержит project_name', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail): bool {
return str_contains($mail->envelope()->subject, 'Caranga');
});
});