4c33323f0e
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>
222 lines
8.5 KiB
PHP
222 lines
8.5 KiB
PHP
<?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'));
|
||
});
|