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>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
<?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(); // не удалено
|
||||
});
|
||||
Reference in New Issue
Block a user