$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')); });