7f5ff874a8
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>
203 lines
8.2 KiB
PHP
203 lines
8.2 KiB
PHP
<?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);
|
||
});
|