Files
portal/app/tests/Frontend/notifications-store.spec.ts
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

220 lines
7.6 KiB
TypeScript
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.
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);
});
});