import { describe, expect, it, vi } from 'vitest'; import { arrayBufferToBase64URL, currentPushSubscription, listenForPushNavigation, notificationPermission, pushSupported, subscribeToPush, subscriptionBody, unsubscribeFromPush, urlBase64ToUint8Array, } from '@/lib/webPush'; /** * Web Push на клиенте (AGENT.md 7.16, Фаза 7): поддержка API, запрос * разрешения, подписка и отписка устройства. Service worker и PushManager в * jsdom отсутствуют, поэтому подменяем их вручную. */ const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/device-one'; /** fakeSubscription — подписка браузера: ключи приходят как ArrayBuffer. */ 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 StubOptions { subscription?: PushSubscription | null; permission?: NotificationPermission; requestPermission?: () => Promise; } /** stubBrowser подменяет Notification, PushManager и serviceWorker. */ function stubBrowser(options: StubOptions = {}) { // Отдельная ссылка на мок отписки: `expect(subscription.unsubscribe)` линтер // считает «оторванным» методом, а так проверка остаётся явной. 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(), }; const requestPermission = vi.fn( options.requestPermission ?? (() => Promise.resolve('granted' as const)), ); vi.stubGlobal('PushManager', class PushManagerStub {}); vi.stubGlobal('Notification', { permission: options.permission ?? 'granted', requestPermission }); Object.defineProperty(navigator, 'serviceWorker', { value: serviceWorker, configurable: true }); return { registration, serviceWorker, subscription, unsubscribe, subscribe: registration.pushManager.subscribe, requestPermission, }; } describe('pushSupported', () => { it('без PushManager и service worker push недоступен', () => { // В jsdom этих API нет по умолчанию: клиент обязан деградировать честно. expect(pushSupported()).toBe(false); expect(notificationPermission()).toBeNull(); }); it('с полным набором API push доступен', () => { stubBrowser(); expect(pushSupported()).toBe(true); expect(notificationPermission()).toBe('granted'); }); }); describe('ключи подписки', () => { it('urlBase64ToUint8Array разбирает base64url без выравнивания', () => { // 0x04 0xfb 0xff — байты, которые дают и «+», и «/», и «-», и «_». const bytes = urlBase64ToUint8Array('BPv_'); expect(Array.from(bytes)).toEqual([4, 251, 255]); }); it('arrayBufferToBase64URL кодирует без padding и в base64url', () => { expect(arrayBufferToBase64URL(new Uint8Array([4, 251, 255]).buffer)).toBe('BPv_'); expect(arrayBufferToBase64URL(null)).toBe(''); }); it('subscriptionBody отдаёт эндпоинт и ключи, а без ключей — null', () => { const body = subscriptionBody(fakeSubscription()); expect(body?.endpoint).toBe(ENDPOINT); expect(body?.keys.p256dh).toHaveLength(87); expect(body?.keys.auth).toHaveLength(22); expect(base64UrlBytes(body?.keys.p256dh ?? '')).toBe(65); expect(base64UrlBytes(body?.keys.auth ?? '')).toBe(16); const broken = { endpoint: ENDPOINT, getKey: () => null, } as unknown as PushSubscription; expect(subscriptionBody(broken)).toBeNull(); }); }); describe('подписка устройства', () => { it('запрашивает разрешение и возвращает тело запроса', async () => { const { subscribe, requestPermission } = stubBrowser({ subscription: null }); const body = await subscribeToPush('BPv_'); expect(requestPermission).toHaveBeenCalled(); expect(subscribe).toHaveBeenCalledWith(expect.objectContaining({ userVisibleOnly: true })); expect(body?.endpoint).toBe(ENDPOINT); }); it('повторно использует уже созданную подписку', async () => { const { subscribe } = stubBrowser(); const body = await subscribeToPush('BPv_'); expect(subscribe).not.toHaveBeenCalled(); expect(body?.endpoint).toBe(ENDPOINT); }); it('молчит, если пользователь запретил уведомления', async () => { const { subscribe } = stubBrowser({ subscription: null, requestPermission: () => Promise.resolve('denied' as const), }); expect(await subscribeToPush('BPv_')).toBeNull(); expect(subscribe).not.toHaveBeenCalled(); }); it('не подписывается без публичного ключа инстанса', async () => { stubBrowser(); expect(await subscribeToPush('')).toBeNull(); }); it('отписка возвращает эндпоинт для запроса к серверу', async () => { const { unsubscribe } = stubBrowser(); const endpoint = await unsubscribeFromPush(); expect(endpoint).toBe(ENDPOINT); expect(unsubscribe).toHaveBeenCalled(); }); it('текущая подписка читается у браузера', async () => { stubBrowser({ subscription: null }); expect(await currentPushSubscription()).toBeNull(); }); }); describe('переход по клику на уведомление', () => { it('меняет маршрут без перезагрузки', () => { const { serviceWorker } = stubBrowser(); const listener = vi.fn(); window.addEventListener('popstate', listener); const stop = listenForPushNavigation(); const handler = serviceWorker.addEventListener.mock.calls[0]?.[1] as ( event: MessageEvent, ) => void; handler({ data: { type: 'push-navigate', url: '/app/1/2' } } as MessageEvent); expect(window.location.pathname).toBe('/app/1/2'); expect(listener).toHaveBeenCalled(); stop(); window.removeEventListener('popstate', listener); expect(serviceWorker.removeEventListener).toHaveBeenCalled(); }); it('игнорирует чужие сообщения и внешние адреса', () => { const { serviceWorker } = stubBrowser(); const before = window.location.pathname; listenForPushNavigation(); const handler = serviceWorker.addEventListener.mock.calls[0]?.[1] as ( event: MessageEvent, ) => void; handler({ data: { type: 'other', url: '/app/1/2' } } as MessageEvent); handler({ data: { type: 'push-navigate', url: 'https://evil.example/' } } as MessageEvent); expect(window.location.pathname).toBe(before); }); }); /** base64UrlBytes — длина декодированного base64url-значения. */ function base64UrlBytes(value: string): number { const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); return window.atob(padded).length; }