203 lines
8.0 KiB
TypeScript
203 lines
8.0 KiB
TypeScript
|
|
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<boolean> = () => 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<NotificationPermission>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 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;
|
|||
|
|
}
|