508de4eaf3
Закрывает этап 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>
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
|
||
// Мокаем api/notifications до import'а store.
|
||
vi.mock('../../resources/js/api/notifications', () => ({
|
||
listNotifications: vi.fn(),
|
||
markNotificationRead: vi.fn(),
|
||
markAllNotificationsRead: vi.fn(),
|
||
deleteNotification: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../resources/js/api/client', () => ({
|
||
apiClient: {},
|
||
ensureCsrfCookie: vi.fn(),
|
||
extractValidationErrors: vi.fn(() => null),
|
||
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
|
||
extractRateLimitRetry: vi.fn(() => null),
|
||
}));
|
||
|
||
import * as notificationsApi from '../../resources/js/api/notifications';
|
||
import { useNotificationsStore } from '../../resources/js/stores/notifications';
|
||
import type { ApiInAppNotification } from '../../resources/js/api/notifications';
|
||
|
||
const mockNotif = (id: number, overrides: Partial<ApiInAppNotification> = {}): ApiInAppNotification => ({
|
||
id,
|
||
event: 'new_lead',
|
||
title: `Уведомление #${id}`,
|
||
body: 'Тестовое тело',
|
||
deal_id: 100 + id,
|
||
payload: { deal_id: 100 + id, project_name: 'Caranga' },
|
||
read_at: null,
|
||
created_at: new Date(2026, 4, 9, 12, id).toISOString(),
|
||
...overrides,
|
||
});
|
||
|
||
describe('useNotificationsStore', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('initial state: items=[], unreadCount=0, total=0', () => {
|
||
const store = useNotificationsStore();
|
||
expect(store.items).toEqual([]);
|
||
expect(store.unreadCount).toBe(0);
|
||
expect(store.total).toBe(0);
|
||
expect(store.fetchError).toBe(false);
|
||
});
|
||
|
||
it('load() заполняет items + unreadCount + total из API', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1), mockNotif(2, { read_at: '2026-05-09T10:00:00Z' })],
|
||
unread_count: 1,
|
||
total: 2,
|
||
});
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
|
||
expect(store.items).toHaveLength(2);
|
||
expect(store.unreadCount).toBe(1);
|
||
expect(store.total).toBe(2);
|
||
expect(store.fetchError).toBe(false);
|
||
});
|
||
|
||
it('load() при reject ставит fetchError=true, items не меняются', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockRejectedValue(new Error('network'));
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
|
||
expect(store.fetchError).toBe(true);
|
||
expect(store.items).toEqual([]);
|
||
});
|
||
|
||
it('markRead() optimistic ставит read_at + уменьшает unreadCount', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1)],
|
||
unread_count: 1,
|
||
total: 1,
|
||
});
|
||
vi.mocked(notificationsApi.markNotificationRead).mockResolvedValue({
|
||
id: 1,
|
||
read_at: '2026-05-09T13:00:00Z',
|
||
});
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
expect(store.unreadCount).toBe(1);
|
||
|
||
await store.markRead(1);
|
||
expect(store.unreadCount).toBe(0);
|
||
expect(store.items[0]!.read_at).toBe('2026-05-09T13:00:00Z');
|
||
});
|
||
|
||
it('markRead() при reject — revert read_at + unreadCount', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1)],
|
||
unread_count: 1,
|
||
total: 1,
|
||
});
|
||
vi.mocked(notificationsApi.markNotificationRead).mockRejectedValue(new Error('500'));
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
await store.markRead(1);
|
||
|
||
expect(store.unreadCount).toBe(1);
|
||
expect(store.items[0]!.read_at).toBeNull();
|
||
});
|
||
|
||
it('markRead() для уже прочитанного НЕ вызывает API', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1, { read_at: '2026-05-09T10:00:00Z' })],
|
||
unread_count: 0,
|
||
total: 1,
|
||
});
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
await store.markRead(1);
|
||
|
||
expect(notificationsApi.markNotificationRead).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('markAllRead() optimistic + вызывает API', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1), mockNotif(2)],
|
||
unread_count: 2,
|
||
total: 2,
|
||
});
|
||
vi.mocked(notificationsApi.markAllNotificationsRead).mockResolvedValue({ updated: 2 });
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
expect(store.unreadCount).toBe(2);
|
||
|
||
await store.markAllRead();
|
||
expect(store.unreadCount).toBe(0);
|
||
expect(store.items.every((n) => n.read_at !== null)).toBe(true);
|
||
expect(notificationsApi.markAllNotificationsRead).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('markAllRead() при unreadCount=0 НЕ вызывает API', async () => {
|
||
const store = useNotificationsStore();
|
||
await store.markAllRead();
|
||
|
||
expect(notificationsApi.markAllNotificationsRead).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('remove() optimistic убирает из items + decrement total', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1), mockNotif(2)],
|
||
unread_count: 2,
|
||
total: 2,
|
||
});
|
||
vi.mocked(notificationsApi.deleteNotification).mockResolvedValue();
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
await store.remove(1);
|
||
|
||
expect(store.items).toHaveLength(1);
|
||
expect(store.items[0]!.id).toBe(2);
|
||
expect(store.total).toBe(1);
|
||
expect(store.unreadCount).toBe(1);
|
||
});
|
||
|
||
it('remove() при reject — revert items + total + unreadCount', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1), mockNotif(2)],
|
||
unread_count: 2,
|
||
total: 2,
|
||
});
|
||
vi.mocked(notificationsApi.deleteNotification).mockRejectedValue(new Error('500'));
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
await store.remove(1);
|
||
|
||
expect(store.items).toHaveLength(2);
|
||
expect(store.total).toBe(2);
|
||
expect(store.unreadCount).toBe(2);
|
||
});
|
||
|
||
it('sortedItems сортирует по created_at DESC', async () => {
|
||
const earlier = mockNotif(1, { created_at: '2026-05-09T10:00:00Z' });
|
||
const later = mockNotif(2, { created_at: '2026-05-09T15:00:00Z' });
|
||
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [earlier, later],
|
||
unread_count: 2,
|
||
total: 2,
|
||
});
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
|
||
expect(store.sortedItems[0]!.id).toBe(2); // later first
|
||
expect(store.sortedItems[1]!.id).toBe(1);
|
||
});
|
||
|
||
it('reset() очищает state', async () => {
|
||
vi.mocked(notificationsApi.listNotifications).mockResolvedValue({
|
||
items: [mockNotif(1)],
|
||
unread_count: 1,
|
||
total: 1,
|
||
});
|
||
|
||
const store = useNotificationsStore();
|
||
await store.load();
|
||
store.reset();
|
||
|
||
expect(store.items).toEqual([]);
|
||
expect(store.unreadCount).toBe(0);
|
||
expect(store.total).toBe(0);
|
||
expect(store.fetchError).toBe(false);
|
||
});
|
||
});
|