Files
portal/app/tests/Feature/Notifications/BalanceNotificationsTest.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

222 lines
8.5 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\InvoicePaidNotification;
use App\Mail\LowBalanceNotification;
use App\Mail\TopupSuccessNotification;
use App\Mail\ZeroBalanceNotification;
use App\Models\InAppNotification;
use App\Models\RejectedDealsLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
uses(DatabaseTransactions::class);
beforeEach(function () {
Mail::fake();
});
function balancePayload(int $vid = 500): array
{
return [
'vid' => $vid,
'project' => 'B2_Caranga',
'tag' => 'Caranga',
'phone' => '79000000'.$vid,
'phones' => ['79000000'.$vid],
'time' => time(),
];
}
function makeUserForBalance(Tenant $tenant, string $email, array $events = []): User
{
return User::factory()->create([
'tenant_id' => $tenant->id,
'email' => $email,
'notification_preferences' => array_merge([
'new_lead' => ['email' => false, 'inapp' => false],
'reminder' => ['email' => true, 'inapp' => true],
'low_balance' => ['email' => true, 'inapp' => true],
'zero_balance' => ['email' => true, 'inapp' => true],
'topup_success' => ['email' => true, 'inapp' => true],
'invoice_paid' => ['email' => true, 'inapp' => true],
'new_device_login' => ['email' => true, 'inapp' => false],
'marketing' => ['email' => false, 'inapp' => false],
], $events),
]);
}
// ============== low_balance ==============
test('low_balance: при пересечении порога сверху-вниз → email + inapp', function () {
// Default threshold: 10 (system_settings seeded). Установим balance=11.
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(10); // 11 → 10 (пересекли порог)
Mail::assertSent(LowBalanceNotification::class, 1);
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
});
test('low_balance: balance уже < threshold — НЕ шлёт повторно', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 5]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(4); // 5 → 4 (всё ещё < threshold=10)
// Не пересекали порог — НЕ шлём.
Mail::assertNothingSent();
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(0);
});
test('low_balance: balance > threshold после decrement — НЕ шлёт', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 50]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(49);
Mail::assertNothingSent();
});
test('low_balance: prefs.low_balance.email=false — только inapp', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
makeUserForBalance($tenant, 'on@example.ru', [
'low_balance' => ['email' => false, 'inapp' => true],
]);
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
Mail::assertNothingSent();
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
});
// ============== zero_balance ==============
test('zero_balance: первое отклонение → email + inapp', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1);
expect(InAppNotification::query()->where('event', 'zero_balance')->count())->toBe(1);
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
});
test('zero_balance: 2-е отклонение в течение часа — НЕ дублирует email', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserForBalance($tenant, 'on@example.ru');
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 1)))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1);
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 2)))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1); // всё ещё один
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
});
test('zero_balance: отклонение через >1ч — снова шлёт', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
makeUserForBalance($tenant, 'on@example.ru');
// Создаём старый RejectedDealsLog (>1ч назад) — он не должен суппрессить.
DB::table('rejected_deals_log')->insert([
'tenant_id' => $tenant->id,
'reason' => RejectedDealsLog::REASON_ZERO_BALANCE,
'payload' => json_encode(['vid' => 999]),
'created_at' => Carbon::now()->subHours(2),
]);
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
Mail::assertSent(ZeroBalanceNotification::class, 1);
});
// ============== topup_success ==============
test('topup_success: notifyTopupSuccess создаёт email + inapp', function () {
$tenant = Tenant::factory()->create();
makeUserForBalance($tenant, 'on@example.ru');
app(NotificationService::class)->notifyTopupSuccess($tenant, '5000.00', 100);
Mail::assertSent(TopupSuccessNotification::class, 1);
Mail::assertSent(function (TopupSuccessNotification $m): bool {
return $m->amountRub === '5000.00' && $m->amountLeads === 100 && $m->hasTo('on@example.ru');
});
expect(InAppNotification::query()->where('event', 'topup_success')->count())->toBe(1);
});
test('topup_success: prefs=email:false — только inapp', function () {
$tenant = Tenant::factory()->create();
makeUserForBalance($tenant, 'on@example.ru', [
'topup_success' => ['email' => false, 'inapp' => true],
]);
app(NotificationService::class)->notifyTopupSuccess($tenant, '1000.00', null);
Mail::assertNothingSent();
expect(InAppNotification::query()->where('event', 'topup_success')->count())->toBe(1);
});
// ============== invoice_paid ==============
test('invoice_paid: notifyInvoicePaid создаёт email + inapp', function () {
$tenant = Tenant::factory()->create();
makeUserForBalance($tenant, 'on@example.ru');
app(NotificationService::class)->notifyInvoicePaid($tenant, '990.00', 'INV-2026-0042', 'Команда');
Mail::assertSent(InvoicePaidNotification::class, 1);
Mail::assertSent(function (InvoicePaidNotification $m): bool {
return $m->amountRub === '990.00'
&& $m->invoiceNumber === 'INV-2026-0042'
&& $m->tariffName === 'Команда';
});
expect(InAppNotification::query()->where('event', 'invoice_paid')->count())->toBe(1);
});
test('invoice_paid: prefs=email:false — только inapp', function () {
$tenant = Tenant::factory()->create();
makeUserForBalance($tenant, 'on@example.ru', [
'invoice_paid' => ['email' => false, 'inapp' => true],
]);
app(NotificationService::class)->notifyInvoicePaid($tenant, '990.00');
Mail::assertNothingSent();
expect(InAppNotification::query()->where('event', 'invoice_paid')->count())->toBe(1);
});
// ============== isolation ==============
test('balance events изолированы между тенантами', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 11]);
$tenantB = Tenant::factory()->create(['balance_leads' => 11]);
$userA = makeUserForBalance($tenantA, 'a@example.ru');
makeUserForBalance($tenantB, 'b@example.ru');
(new ProcessWebhookJob($tenantA->id, balancePayload()))->handle();
Mail::assertSent(LowBalanceNotification::class, 1);
Mail::assertSent(fn (LowBalanceNotification $m) => $m->hasTo($userA->email));
Mail::assertNotSent(fn (LowBalanceNotification $m) => $m->hasTo('b@example.ru'));
});