import { describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClientProvider } from '@tanstack/react-query'; import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection'; import { createTestQueryClient, installFetch, json, recordedRequests, type FetchRoute, } from './helpers'; /** * Web Push в настройках уведомлений (AGENT.md 7.16, Фаза 7): раздел с * тумблером, запросом разрешения и честной деградацией, когда браузер или * инстанс push не поддерживают. */ const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/settings-device'; /** Подписка, которую «выдаёт» браузер после subscribe(). */ function fakeSubscription( endpoint = ENDPOINT, unsubscribe: () => Promise = () => Promise.resolve(true), ) { const buffer = (bytes: number[]): ArrayBuffer => new Uint8Array(bytes).buffer; return { endpoint, getKey: (name: string) => name === 'p256dh' ? buffer(Array.from({ length: 65 }, (_, index) => index)) : buffer(Array.from({ length: 16 }, (_, index) => index + 1)), unsubscribe, } as unknown as PushSubscription; } interface BrowserOptions { subscription?: PushSubscription | null; permission?: NotificationPermission; requestPermission?: () => Promise; } /** stubBrowser подменяет Notification, PushManager и service worker. */ function stubBrowser(options: BrowserOptions = {}) { const unsubscribe = vi.fn(() => Promise.resolve(true)); const subscription = options.subscription === undefined ? fakeSubscription(ENDPOINT, unsubscribe) : options.subscription; const registration = { pushManager: { getSubscription: vi.fn(() => Promise.resolve(subscription)), subscribe: vi.fn(() => Promise.resolve(subscription ?? fakeSubscription())), }, }; const serviceWorker = { getRegistration: vi.fn(() => Promise.resolve(registration)), register: vi.fn(() => Promise.resolve(registration)), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; vi.stubGlobal('PushManager', class PushManagerStub {}); vi.stubGlobal('Notification', { permission: options.permission ?? 'granted', requestPermission: vi.fn( options.requestPermission ?? (() => Promise.resolve('granted' as const)), ), }); Object.defineProperty(navigator, 'serviceWorker', { value: serviceWorker, configurable: true }); return { registration, serviceWorker, subscription: subscription ?? fakeSubscription(), unsubscribe, }; } function renderSection(routes: FetchRoute[]) { const fetchMock = installFetch(routes); const client = createTestQueryClient(); render( , ); return fetchMock; } describe('раздел push в настройках уведомлений', () => { it('без поддержки push показывает пояснение и не спрашивает сервер', async () => { const fetchMock = renderSection([]); expect(await screen.findByText(/не поддерживает push-уведомления/i)).toBeInTheDocument(); expect(screen.queryByTestId('push-toggle')).not.toBeInTheDocument(); expect(recordedRequests(fetchMock)).toHaveLength(0); }); it('на инстансе без ключей честно сообщает, что push выключен', async () => { stubBrowser({ subscription: null }); renderSection([{ match: '/push/config', response: () => json({ enabled: false }) }]); expect(await screen.findByText(/push-уведомления не настроены/i)).toBeInTheDocument(); expect(screen.queryByTestId('push-toggle')).not.toBeInTheDocument(); }); it('включает push: подписывает браузер и отправляет подписку на сервер', async () => { stubBrowser({ subscription: null }); const fetchMock = renderSection([ { match: '/push/config', response: () => json({ enabled: true, public_key: 'BPv_public_key' }), }, { match: '/push/subscriptions', method: 'POST', response: () => json({ ok: true }) }, ]); const toggle = await screen.findByTestId('push-toggle'); await waitFor(() => expect(toggle).toBeEnabled()); await userEvent.click(toggle); expect(await screen.findByTestId('push-active')).toBeInTheDocument(); const request = recordedRequests(fetchMock).find( (item) => item.method === 'POST' && item.url.includes('/push/subscriptions'), ); expect(request?.body).toMatchObject({ endpoint: ENDPOINT }); }); it('показывает активное состояние, если устройство уже подписано', async () => { stubBrowser(); renderSection([ { match: '/push/config', response: () => json({ enabled: true, public_key: 'BPv_public_key' }), }, ]); expect(await screen.findByTestId('push-active')).toBeInTheDocument(); expect(await screen.findByTestId('push-toggle')).toHaveTextContent(/выключить/i); }); it('выключает push: отписывает браузер и сервер', async () => { const { unsubscribe } = stubBrowser(); const fetchMock = renderSection([ { match: '/push/config', response: () => json({ enabled: true, public_key: 'BPv_public_key' }), }, { match: '/push/subscriptions', method: 'DELETE', response: () => json({ ok: true }), }, ]); const toggle = await screen.findByTestId('push-toggle'); await waitFor(() => expect(toggle).toHaveTextContent(/выключить/i)); await userEvent.click(toggle); await waitFor(() => expect(screen.queryByTestId('push-active')).not.toBeInTheDocument()); expect(unsubscribe).toHaveBeenCalled(); const request = recordedRequests(fetchMock).find( (item) => item.method === 'DELETE' && item.url.includes('/push/subscriptions'), ); expect(request?.url).toContain(encodeURIComponent(ENDPOINT)); }); it('объясняет отказ браузера в разрешении', async () => { stubBrowser({ subscription: null, permission: 'denied', requestPermission: () => Promise.resolve('denied' as const), }); renderSection([ { match: '/push/config', response: () => json({ enabled: true, public_key: 'BPv_public_key' }), }, ]); expect(await screen.findByTestId('push-denied')).toBeInTheDocument(); }); it('показывает ошибку загрузки параметров и умеет повторить', async () => { stubBrowser({ subscription: null }); renderSection([ { match: '/push/config', response: () => json({ error: { code: 'internal.error', message: 'x' } }, 500), }, ]); expect(await screen.findByRole('alert')).toBeInTheDocument(); }); });