Files
portal/app/tests/Feature/Notifications/InAppNotificationApiTest.php
T
Дмитрий 508de4eaf3 phase2(notifications-stage2b): API + Pinia + bell в AppLayout (P0 этап 2b)
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).

Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
  GET /api/notifications?unread_only=&limit= (1..100 default 50);
  PATCH /api/notifications/{id}/read (idempotent);
  POST /api/notifications/mark-all-read (bulk + count);
  DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).

Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
  optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
  pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
  v-list последних 10 из sortedItems с event-icon + formatRelative;
  Mark-all-read btn только при unreadCount > 0;
  click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
  (bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
  на mount с user / no-op без user).

PHPStan baseline регенерирован (50 Pest false-positives подавлены).

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

179 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\InAppNotification;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* Тесты API для in-app уведомлений: GET /api/notifications + PATCH /{id}/read +
* POST /mark-all-read + DELETE /{id}. Все endpoint'ы под Sanctum SPA auth.
*
* RLS-проверка через защиту от кражи чужого id (where user_id) — постgres
* superuser BYPASSRLS в тестах, но controller-level фильтр работает.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
function makeNotif(int $userId, int $tenantId, ?string $readAt = null, string $event = 'new_lead'): InAppNotification
{
return InAppNotification::create([
'tenant_id' => $tenantId,
'user_id' => $userId,
'event' => $event,
'title' => 'Новый лид — Caranga',
'body' => '79001234567',
'deal_id' => 42,
'payload' => ['deal_id' => 42, 'project_name' => 'Caranga'],
'read_at' => $readAt,
]);
}
test('GET /api/notifications: 401 без auth', function () {
auth()->logout();
$this->getJson('/api/notifications')->assertStatus(401);
});
test('GET /api/notifications: пустой', function () {
$response = $this->getJson('/api/notifications');
$response->assertOk();
expect($response->json('items'))->toBe([]);
expect($response->json('unread_count'))->toBe(0);
expect($response->json('total'))->toBe(0);
});
test('GET /api/notifications: возвращает только свои + сортировка по created_at DESC', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
makeNotif($this->user->id, $this->tenant->id);
sleep(1);
$newer = makeNotif($this->user->id, $this->tenant->id);
makeNotif($other->id, $this->tenant->id); // чужое
$response = $this->getJson('/api/notifications');
$response->assertOk();
expect($response->json('total'))->toBe(2);
expect($response->json('items.0.id'))->toBe($newer->id); // newer first
expect($response->json('unread_count'))->toBe(2);
});
test('GET /api/notifications?unread_only=1: только непрочитанные', function () {
makeNotif($this->user->id, $this->tenant->id, readAt: now()->toIso8601String());
makeNotif($this->user->id, $this->tenant->id);
$response = $this->getJson('/api/notifications?unread_only=1');
expect($response->json('items'))->toHaveCount(1);
expect($response->json('items.0.read_at'))->toBeNull();
expect($response->json('unread_count'))->toBe(1);
expect($response->json('total'))->toBe(2);
});
test('GET /api/notifications?limit=2: лимитирует выдачу', function () {
for ($i = 0; $i < 5; $i++) {
makeNotif($this->user->id, $this->tenant->id);
}
$response = $this->getJson('/api/notifications?limit=2');
expect($response->json('items'))->toHaveCount(2);
expect($response->json('total'))->toBe(5);
});
test('GET /api/notifications: 422 на limit > 100', function () {
$this->getJson('/api/notifications?limit=101')->assertStatus(422);
});
test('GET /api/notifications: возвращает поля title/body/event/payload/deal_id', function () {
$notif = makeNotif($this->user->id, $this->tenant->id);
$response = $this->getJson('/api/notifications');
$item = $response->json('items.0');
expect($item['id'])->toBe($notif->id);
expect($item['event'])->toBe('new_lead');
expect($item['title'])->toBe('Новый лид — Caranga');
expect($item['body'])->toBe('79001234567');
expect($item['deal_id'])->toBe(42);
expect($item['payload']['project_name'])->toBe('Caranga');
expect($item['read_at'])->toBeNull();
});
test('PATCH /api/notifications/{id}/read: ставит read_at + idempotent', function () {
$notif = makeNotif($this->user->id, $this->tenant->id);
$response = $this->patchJson("/api/notifications/{$notif->id}/read");
$response->assertOk();
expect($response->json('read_at'))->not->toBeNull();
$notif->refresh();
$firstReadAt = $notif->read_at?->toIso8601String();
expect($firstReadAt)->not->toBeNull();
// Повторный — не меняет read_at.
$this->patchJson("/api/notifications/{$notif->id}/read")->assertOk();
$notif->refresh();
expect($notif->read_at?->toIso8601String())->toBe($firstReadAt);
});
test('PATCH /api/notifications/{id}/read: 404 для чужого', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
$notif = makeNotif($other->id, $this->tenant->id);
$this->patchJson("/api/notifications/{$notif->id}/read")->assertStatus(404);
});
test('PATCH /api/notifications/{id}/read: 404 на несуществующий id', function () {
$this->patchJson('/api/notifications/999999/read')->assertStatus(404);
});
test('POST /api/notifications/mark-all-read: bulk-update + count', function () {
makeNotif($this->user->id, $this->tenant->id);
makeNotif($this->user->id, $this->tenant->id);
makeNotif($this->user->id, $this->tenant->id, readAt: now()->toIso8601String()); // уже прочитано
$response = $this->postJson('/api/notifications/mark-all-read');
$response->assertOk();
expect($response->json('updated'))->toBe(2);
$unreadCount = InAppNotification::query()
->where('user_id', $this->user->id)
->whereNull('read_at')
->count();
expect($unreadCount)->toBe(0);
});
test('POST /api/notifications/mark-all-read: только свои', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
makeNotif($other->id, $this->tenant->id); // чужое
makeNotif($this->user->id, $this->tenant->id);
$response = $this->postJson('/api/notifications/mark-all-read');
expect($response->json('updated'))->toBe(1); // только своё
$otherUnread = InAppNotification::query()
->where('user_id', $other->id)
->whereNull('read_at')
->count();
expect($otherUnread)->toBe(1); // чужое осталось непрочитанным
});
test('DELETE /api/notifications/{id}: удаляет своё', function () {
$notif = makeNotif($this->user->id, $this->tenant->id);
$this->deleteJson("/api/notifications/{$notif->id}")->assertOk();
expect(InAppNotification::query()->find($notif->id))->toBeNull();
});
test('DELETE /api/notifications/{id}: 404 для чужого', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
$notif = makeNotif($other->id, $this->tenant->id);
$this->deleteJson("/api/notifications/{$notif->id}")->assertStatus(404);
expect(InAppNotification::query()->find($notif->id))->not->toBeNull(); // не удалено
});