Files
portal/app/tests/Feature/Notifications/InAppNotificationTest.php
T
Дмитрий 7f5ff874a8 phase2(notifications-stage2a): in_app_notifications + notifyInApp (schema v8.10)
P0 этап 2a — backend-фундамент bell-icon канала. UI bell + API endpoints
— этап 2b отдельным коммитом.

Schema v8.9 → v8.10:
- Новая таблица in_app_notifications (после reminders в schema):
  id/tenant_id/user_id/event/title/body/deal_id/payload/read_at/created_at.
- 2 индекса: unread (user_id, created_at DESC) WHERE read_at IS NULL
  + recent (user_id, created_at DESC).
- RLS tenant_isolation.
- Метрики: 55→56 таблиц, 93→95 индексов, 36→37 RLS.
- CHANGELOG_schema.md +§T.

Backend:
- App\Models\InAppNotification — Eloquent с UPDATED_AT=null.
- NotificationService::notifyInApp — INSERT через DB::transaction + SET
  LOCAL app.current_tenant_id для RLS. Throwable + Log::warning.
- notifyNewLead шлёт два канала параллельно: email + inapp.

Pest +11 (291/291 за 32.94 сек, 1060 assertions):
- inapp=true/false; schema-default (inapp=true в схеме);
- 2 user'а получают / inactive не получает / RLS изоляция;
- дубль Биз-19 / повторный vid / оба канала / payload deal_id;
- notifyInApp напрямую с reminder.

PHPStan baseline регенерирован. IDE-helper для InAppNotification.
cspell-words: +inapp.

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

203 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\InAppNotification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
/**
* Тесты in-app канала уведомлений (schema v8.10 in_app_notifications).
*
* Канал inapp в матрице users.notification_preferences. INSERT row при
* триггере события (new_lead/...). UI читает unread-count и список
* последних 50 (этап 2b — отдельный коммит).
*
* Schema-default: notification_preferences.new_lead.inapp=true → в отличие
* от email, большинство user'ов получает in-app по умолчанию.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
Mail::fake();
});
/**
* @return array<string, mixed>
*/
function inAppPayload(int $vid = 300, ?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 makeUserWithInAppPrefs(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: in_app_notification создаётся для user с inapp=true', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$user = makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true, 'email' => false]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
$notif = InAppNotification::query()->first();
expect($notif->user_id)->toBe($user->id);
expect($notif->tenant_id)->toBe($tenant->id);
expect($notif->event)->toBe('new_lead');
expect($notif->title)->toContain('Caranga');
expect($notif->body)->toBe('79001234567'); // phone (no contact_name)
expect($notif->read_at)->toBeNull();
expect($notif->payload['project_name'])->toBe('Caranga');
});
test('webhook: user с inapp=false НЕ получает in-app row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'off@example.ru', ['inapp' => false, 'email' => false]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(0);
});
test('webhook: schema-default (inapp=true) ставит row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
// Без override prefs — берётся schema DEFAULT (new_lead.inapp=true).
User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'default@example.ru',
]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
});
test('webhook: 2 user\'а с inapp=true получают по 1 row, 1 user с inapp=false — нет', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$a = makeUserWithInAppPrefs($tenant, 'a@example.ru', ['inapp' => true]);
$b = makeUserWithInAppPrefs($tenant, 'b@example.ru', ['inapp' => true]);
makeUserWithInAppPrefs($tenant, 'c@example.ru', ['inapp' => false]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(2);
expect(InAppNotification::query()->where('user_id', $a->id)->exists())->toBeTrue();
expect(InAppNotification::query()->where('user_id', $b->id)->exists())->toBeTrue();
});
test('webhook: inactive user НЕ получает in-app', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
User::factory()->inactive()->create([
'tenant_id' => $tenant->id,
'email' => 'inactive@example.ru',
'notification_preferences' => ['new_lead' => ['inapp' => true]],
]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(0);
});
test('webhook: user другого тенанта НЕ получает (RLS isolation)', function () {
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
$userA = makeUserWithInAppPrefs($tenantA, 'a@example.ru', ['inapp' => true]);
makeUserWithInAppPrefs($tenantB, 'b@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenantA->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
expect(InAppNotification::query()->first()->user_id)->toBe($userA->id);
});
test('webhook: дубль (Биз-19) НЕ создаёт повторный in-app row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 1)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
// Второй webhook с тем же phone в окне 24ч → дубль, нет chargeNewLead → нет notify.
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 2)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
});
test('webhook: повторный vid (UPDATE) НЕ создаёт повторный in-app row', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
expect(InAppNotification::query()->count())->toBe(1);
});
test('webhook: оба канала (inapp+email=true) — 1 in-app row + 1 email', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'both@example.ru', ['inapp' => true, 'email' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
expect(InAppNotification::query()->count())->toBe(1);
Mail::assertSent(NewLeadNotification::class, 1);
});
test('webhook: payload содержит deal_id для UI deep-link', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
$notif = InAppNotification::query()->first();
expect($notif->deal_id)->not->toBeNull();
expect($notif->payload)->toHaveKey('deal_id');
expect($notif->payload['deal_id'])->toBe($notif->deal_id);
});
test('NotificationService::notifyInApp: вызов напрямую создаёт row', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$service = app(NotificationService::class);
$service->notifyInApp($user, 'reminder', 'Срок касания', 'Перезвонить клиенту через 30 мин', ['deal_id' => 42]);
$notif = InAppNotification::query()->first();
expect($notif)->not->toBeNull();
expect($notif->event)->toBe('reminder');
expect($notif->title)->toBe('Срок касания');
expect($notif->body)->toBe('Перезвонить клиенту через 30 мин');
expect($notif->deal_id)->toBe(42);
expect($notif->payload['deal_id'])->toBe(42);
});