Files
glchat/web/tests/webPush.test.ts
T
grendervill f45cc21a9c feat(web): push-уведомления в браузере и раздел в настройках (7.16)
Клиент подписывает устройство на Web Push, а показывает уведомление service
worker — приложение при этом может быть и закрыто.

- `web/public/sw.js`: обработчики `push` (показывает уведомление, иначе браузер
  может отозвать подписку) и `notificationclick` (фокусирует окно и переводит
  его в комнату, иначе открывает новое). Путь из payload берётся только
  внутренний: `//host` и абсолютные адреса отбрасываются;
- `web/src/lib/webPush.ts`: поддержка API, запрос разрешения, подписка и
  отписка, преобразование ключей в base64url, слушатель перехода по клику
  (`push-navigate` → `history.pushState` без перезагрузки);
- `web/src/api/push.ts`: `GET /push/config`, `GET|POST|DELETE /push/subscriptions`;
- раздел «Push-уведомления» в настройках: тумблер «Включить на этом
  устройстве», состояние «включено», объяснение отказа браузера в разрешении и
  честная деградация — «браузер не поддерживает» или «на инстансе не настроено»;
- владелец инстанса управляет ключами, клиент только спрашивает публичный.

Тесты: `web/tests/webPush.test.ts` (поддержка API, ключи, подписка, отписка,
отказ в разрешении, переход по клику) и `web/tests/pushSettings.test.tsx`
(раздел настроек: деградация, включение, отключение, ошибка загрузки).
2026-09-26 16:15:24 +03:00

203 lines
8.0 KiB
TypeScript
Raw Permalink 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, 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;
}