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`
(раздел настроек: деградация, включение, отключение, ошибка загрузки).
This commit is contained in:
2026-09-26 16:15:24 +03:00
parent a9b1073877
commit f45cc21a9c
9 changed files with 888 additions and 8 deletions
+68
View File
@@ -89,3 +89,71 @@ self.addEventListener('fetch', (event) => {
.catch(() => caches.match(request).then((cached) => cached ?? Response.error())), .catch(() => caches.match(request).then((cached) => cached ?? Response.error())),
); );
}); });
/*
* Web Push (AGENT.md 7.16, Фаза 7).
*
* Текст уведомления готовит сервер (internal/push/rules.go) по тем же
* правилам, что нативные уведомления desktop-обёртки, а воркер только
* показывает его и открывает комнату по клику. Уведомление обязано
* показываться на каждый push: иначе браузер может отозвать подписку.
*/
/** sameOriginPath оставляет только внутренний путь: payload приходит извне. */
function sameOriginPath(value) {
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
return '/app';
}
return value;
}
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}
let payload = null;
try {
payload = event.data.json();
} catch {
return;
}
if (payload === null || typeof payload !== 'object') {
return;
}
const title =
typeof payload.title === 'string' && payload.title !== '' ? payload.title : 'glchat';
const body = typeof payload.body === 'string' ? payload.body : '';
const options = {
body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
// Уведомления одной комнаты заменяют друг друга (Topic на сервере).
tag: typeof payload.tag === 'string' && payload.tag !== '' ? payload.tag : undefined,
data: {
url: sameOriginPath(payload.url),
channel_id: typeof payload.channel_id === 'string' ? payload.channel_id : '',
guild_id: typeof payload.guild_id === 'string' ? payload.guild_id : '',
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = sameOriginPath(event.notification.data && event.notification.data.url);
event.waitUntil(
(async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of windows) {
if ('focus' in client) {
await client.focus();
// Открытое окно переходит в комнату без перезагрузки: клиент слушает
// это сообщение и делает history.pushState (web/src/lib/webPush.ts).
client.postMessage({ type: 'push-navigate', url: target });
return;
}
}
await self.clients.openWindow(target);
})(),
);
});
+54
View File
@@ -0,0 +1,54 @@
import { request } from './client';
/**
* Web Push (AGENT.md 7.16, Фаза 7): параметры push и подписки устройств.
*
* Публичный VAPID-ключ нужен браузеру как `applicationServerKey`; приватный
* остаётся на сервере. Подписка — это устройство: одно и то же устройство
* может переподписаться, тогда запись обновляется.
*/
export interface PushConfig {
enabled: boolean;
public_key?: string;
}
export interface PushSubscriptionInfo {
id: string;
endpoint: string;
user_agent?: string;
created_at: string;
}
export interface PushSubscriptionList {
subscriptions: PushSubscriptionInfo[];
/** Сколько устройств можно подписать (лимит инстанса). */
limit: number;
}
export const pushConfigQueryKey = ['push-config'] as const;
export const pushSubscriptionsQueryKey = ['push-subscriptions'] as const;
export function fetchPushConfig(signal?: AbortSignal): Promise<PushConfig> {
return request<PushConfig>('/push/config', signal === undefined ? {} : { signal });
}
export function listPushSubscriptions(signal?: AbortSignal): Promise<PushSubscriptionList> {
return request<PushSubscriptionList>(
'/push/subscriptions',
signal === undefined ? {} : { signal },
);
}
export function subscribePush(body: {
endpoint: string;
keys: { p256dh: string; auth: string };
}): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/push/subscriptions', { method: 'POST', body });
}
/** Отписка: без эндпоинта сервер убирает все устройства пользователя. */
export function unsubscribePush(endpoint?: string): Promise<{ ok: boolean }> {
const query = endpoint === undefined ? '' : `?endpoint=${encodeURIComponent(endpoint)}`;
return request<{ ok: boolean }>(`/push/subscriptions${query}`, { method: 'DELETE' });
}
+16 -2
View File
@@ -124,7 +124,11 @@
"event_required": "Choose an event for a palette sound." "event_required": "Choose an event for a palette sound."
}, },
"voice.not_connected": "Join a voice channel first.", "voice.not_connected": "Join a voice channel first.",
"user.banned": "This account is banned on the instance. Contact the administrator." "user.banned": "This account is banned on the instance. Contact the administrator.",
"push.disabled": "Push notifications are disabled on this instance.",
"push.invalid_endpoint": "The browser sent an invalid push service endpoint.",
"push.invalid_keys": "The browser sent invalid subscription keys.",
"push.too_many_subscriptions": "Too many devices with push notifications: disable some of them."
}, },
"theme": { "theme": {
"switchToLight": "Light theme", "switchToLight": "Light theme",
@@ -1203,7 +1207,17 @@
"desktop": "Desktop notifications", "desktop": "Desktop notifications",
"desktopHint": "Show system notifications when the window is inactive or the channel is not open. Native in the desktop app, permission-based in the browser.", "desktopHint": "Show system notifications when the window is inactive or the channel is not open. Native in the desktop app, permission-based in the browser.",
"desktopSettings": "Desktop app settings", "desktopSettings": "Desktop app settings",
"unsupported": "This browser does not show system notifications: open the client in the desktop app." "unsupported": "This browser does not show system notifications: open the client in the desktop app.",
"push": "Push notifications",
"pushHint": "Notifications are delivered to this device even when the browser is closed. Works in Chrome, Firefox, Safari and Edge on desktop and mobile.",
"pushEnable": "Enable on this device",
"pushDisable": "Disable on this device",
"pushActive": "Push notifications are enabled on this device.",
"pushDenied": "The browser blocked notifications. Allow them in the site settings and try again.",
"pushUnsupported": "This browser does not support push notifications.",
"pushUnavailable": "Push notifications are not configured on this instance.",
"pushError": "Could not enable push notifications. Please try again.",
"pushWorking": "Setting up…"
}, },
"voice": { "voice": {
"title": "Audio & video", "title": "Audio & video",
+16 -2
View File
@@ -124,7 +124,11 @@
"event_required": "Для звука палитры выберите событие." "event_required": "Для звука палитры выберите событие."
}, },
"voice.not_connected": "Сначала войдите в голосовую комнату.", "voice.not_connected": "Сначала войдите в голосовую комнату.",
"user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору." "user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору.",
"push.disabled": "Push-уведомления на инстансе выключены.",
"push.invalid_endpoint": "Браузер прислал некорректный адрес push-сервиса.",
"push.invalid_keys": "Браузер прислал некорректные ключи подписки.",
"push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние."
}, },
"theme": { "theme": {
"switchToLight": "Светлая тема", "switchToLight": "Светлая тема",
@@ -1203,7 +1207,17 @@
"desktop": "Уведомления рабочего стола", "desktop": "Уведомления рабочего стола",
"desktopHint": "Показывать системные уведомления, когда окно неактивно или комната не открыта. В desktop-приложении уведомления нативные, в браузере — при разрешённых уведомлениях.", "desktopHint": "Показывать системные уведомления, когда окно неактивно или комната не открыта. В desktop-приложении уведомления нативные, в браузере — при разрешённых уведомлениях.",
"desktopSettings": "Настройки desktop-приложения", "desktopSettings": "Настройки desktop-приложения",
"unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении." "unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении.",
"push": "Push-уведомления",
"pushHint": "Уведомления приходят на это устройство, даже когда браузер закрыт. Работает в Chrome, Firefox, Safari, Edge на компьютере и на телефоне.",
"pushEnable": "Включить на этом устройстве",
"pushDisable": "Выключить на этом устройстве",
"pushActive": "Push-уведомления включены на этом устройстве.",
"pushDenied": "Браузер запретил уведомления. Разрешите их в настройках сайта и повторите.",
"pushUnsupported": "Этот браузер не поддерживает push-уведомления.",
"pushUnavailable": "На этом инстансе push-уведомления не настроены.",
"pushError": "Не удалось включить push-уведомления. Попробуйте ещё раз.",
"pushWorking": "Настраиваем…"
}, },
"voice": { "voice": {
"title": "Аудио-видео", "title": "Аудио-видео",
+171
View File
@@ -0,0 +1,171 @@
/**
* Web Push в браузере (AGENT.md 7.16, Фаза 7).
*
* Модуль отвечает только за подписку устройства: поддержку API, запрос
* разрешения, регистрацию service worker и преобразование подписки в тело
* запроса к серверу. Решение «показывать уведомление или нет» принимает
* сервер (internal/push/rules.go), а показывает его service worker
* (web/public/sw.js) — приложение для этого может быть и закрыто.
*/
/** Состояние push на этом устройстве. */
export type PushAvailability =
| 'unsupported' // браузер не умеет push или service worker
| 'unavailable' // на инстансе не настроены VAPID-ключи
| 'denied' // пользователь запретил уведомления в браузере
| 'off' // поддерживается, но устройство не подписано
| 'on'; // устройство подписано
/** Поддерживает ли браузер всё необходимое. */
export function pushSupported(): boolean {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
/** Текущее разрешение браузера на уведомления. */
export function notificationPermission(): NotificationPermission | null {
if (!pushSupported()) {
return null;
}
return Notification.permission;
}
/**
* urlBase64ToUint8Array переводит VAPID-ключ сервера в `applicationServerKey`.
* Ключ приходит в base64url без выравнивания — добавляем padding сами.
*/
export function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
const binary = window.atob(padded);
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
/** Тело запроса подписки: то, что ждёт сервер (endpoint + ключи). */
export interface PushSubscriptionBody {
endpoint: string;
keys: { p256dh: string; auth: string };
}
/** arrayBufferToBase64URL кодирует ключи подписки в base64url без padding. */
export function arrayBufferToBase64URL(buffer: ArrayBuffer | null): string {
if (buffer === null) {
return '';
}
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Приводит PushSubscription к телу запроса; null, если ключей нет. */
export function subscriptionBody(subscription: PushSubscription): PushSubscriptionBody | null {
const p256dh = arrayBufferToBase64URL(subscription.getKey('p256dh'));
const auth = arrayBufferToBase64URL(subscription.getKey('auth'));
if (p256dh === '' || auth === '' || subscription.endpoint === '') {
return null;
}
return { endpoint: subscription.endpoint, keys: { p256dh, auth } };
}
/**
* ensureServiceWorker дожидается регистрации воркера. Обычно он уже
* зарегистрирован при запуске приложения (web/src/lib/serviceWorker.ts), но в
* dev-сборке регистрация не выполняется — для push регистрируем явно.
*/
async function ensureServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!pushSupported()) {
return null;
}
const existing = await navigator.serviceWorker.getRegistration('/');
if (existing !== undefined) {
return existing;
}
try {
return await navigator.serviceWorker.register('/sw.js', { scope: '/' });
} catch (error) {
console.warn('service worker registration failed', error);
return null;
}
}
/** Текущая подписка устройства или null. */
export async function currentPushSubscription(): Promise<PushSubscription | null> {
const registration = await ensureServiceWorker();
if (registration === null) {
return null;
}
return registration.pushManager.getSubscription();
}
/**
* Подписывает устройство. Возвращает тело запроса для сервера или null, если
* подписка не удалась (нет поддержки, отказ пользователя, ошибка браузера).
*/
export async function subscribeToPush(publicKey: string): Promise<PushSubscriptionBody | null> {
const registration = await ensureServiceWorker();
if (registration === null || publicKey === '') {
return null;
}
// Разрешение запрашиваем до subscribe: браузеры требуют пользовательский жест.
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return null;
}
const existing = await registration.pushManager.getSubscription();
const subscription =
existing ??
(await registration.pushManager.subscribe({
// userVisibleOnly обязателен: браузер требует показывать уведомление.
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
}));
return subscriptionBody(subscription);
}
/** Отписывает устройство и возвращает его эндпоинт (для запроса к серверу). */
export async function unsubscribeFromPush(): Promise<string | null> {
const subscription = await currentPushSubscription();
if (subscription === null) {
return null;
}
const endpoint = subscription.endpoint;
try {
await subscription.unsubscribe();
} catch (error) {
console.warn('push unsubscribe failed', error);
}
return endpoint;
}
/**
* Слушает переход по клику на уведомление: service worker присылает
* сообщение `push-navigate`, а приложение меняет маршрут без перезагрузки.
* Возвращает функцию отписки.
*/
export function listenForPushNavigation(): () => void {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return () => {};
}
const listener = (event: MessageEvent): void => {
const data = event.data as { type?: unknown; url?: unknown } | null;
if (data === null || data.type !== 'push-navigate' || typeof data.url !== 'string') {
return;
}
if (!data.url.startsWith('/')) {
return;
}
window.history.pushState(null, '', data.url);
window.dispatchEvent(new PopStateEvent('popstate'));
};
navigator.serviceWorker.addEventListener('message', listener);
return () => navigator.serviceWorker.removeEventListener('message', listener);
}
+3
View File
@@ -5,12 +5,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { App } from '@/App'; import { App } from '@/App';
import { getQueryClient } from '@/lib/queryClient'; import { getQueryClient } from '@/lib/queryClient';
import { registerServiceWorker } from '@/lib/serviceWorker'; import { registerServiceWorker } from '@/lib/serviceWorker';
import { listenForPushNavigation } from '@/lib/webPush';
import { applyTheme, useUiStore } from '@/stores/ui'; import { applyTheme, useUiStore } from '@/stores/ui';
import '@/i18n'; import '@/i18n';
import '@/index.css'; import '@/index.css';
applyTheme(useUiStore.getState().theme); applyTheme(useUiStore.getState().theme);
registerServiceWorker(); registerServiceWorker();
// Клик по push-уведомлению переводит уже открытое окно в комнату (Фаза 7).
listenForPushNavigation();
const container = document.getElementById('root'); const container = document.getElementById('root');
if (!container) { if (!container) {
@@ -1,22 +1,132 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { fetchPushConfig, pushConfigQueryKey, subscribePush, unsubscribePush } from '@/api/push';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button } from '@/components/ui/primitives';
import { notificationsEnabled, setNotificationsEnabled } from '@/lib/desktopNotifications'; import { notificationsEnabled, setNotificationsEnabled } from '@/lib/desktopNotifications';
import { platform } from '@/lib/platform'; import { platform } from '@/lib/platform';
import {
currentPushSubscription,
notificationPermission,
pushSupported,
subscribeToPush,
unsubscribeFromPush,
type PushAvailability,
} from '@/lib/webPush';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
/** /**
* «Уведомления»: системные уведомления об упоминаниях и личных сообщениях. * «Уведомления»: системные уведомления об упоминаниях и личных сообщениях.
* *
* Предпочтение хранится на устройстве (localStorage): в desktop-обёртке * Предпочтение «показывать уведомления» хранится на устройстве (localStorage):
* уведомления нативные, в браузере — только при выданном разрешении, а * в desktop-обёртке уведомления нативные, в браузере — системные. Отдельно
* веб-пуш (Фаза 7) появится отдельным пунктом. * включается Web Push (Фаза 7): тогда уведомления приходят, даже когда
* браузер закрыт, а подписку хранит сервер.
*/ */
export function NotificationsSettingsSection() { export function NotificationsSettingsSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [enabled, setEnabled] = useState(notificationsEnabled); const [enabled, setEnabled] = useState(notificationsEnabled);
// В обёртке уведомления всегда доступны, в браузере — по разрешению. // В обёртке уведомления всегда доступны, в браузере — по разрешению.
const supported = platform.kind === 'tauri' || 'Notification' in window; const supported = platform.kind === 'tauri' || 'Notification' in window;
const pushApiSupported = pushSupported();
const config = useQuery({
queryKey: pushConfigQueryKey,
queryFn: ({ signal }) => fetchPushConfig(signal),
enabled: pushApiSupported,
// Ключи меняются только при переустановке инстанса: кэш свеж долго.
staleTime: 30 * 60 * 1000,
retry: 0,
});
const pushEnabledOnInstance = config.data?.enabled === true;
const publicKey = config.data?.public_key ?? '';
const [subscriptionState, setSubscriptionState] = useState<'unknown' | 'on' | 'off' | 'denied'>(
'unknown',
);
const [busy, setBusy] = useState(false);
/*
* Состояние подписки читаем у браузера: сервер знает только про устройства,
* а нам важно именно это. В эффекте — только асинхронная работа: состояние
* «не поддерживается» и «на инстансе выключено» вычисляются при отрисовке,
* иначе получаются лишние перерисовки (правило react-hooks).
*/
useEffect(() => {
if (!pushApiSupported || !config.isSuccess || !pushEnabledOnInstance) {
return;
}
let cancelled = false;
void currentPushSubscription().then((subscription) => {
if (cancelled) {
return;
}
if (subscription !== null) {
setSubscriptionState('on');
return;
}
setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off');
});
return () => {
cancelled = true;
};
}, [pushApiSupported, config.isSuccess, pushEnabledOnInstance]);
const availability: PushAvailability = !pushApiSupported
? 'unsupported'
: config.isSuccess && !pushEnabledOnInstance
? 'unavailable'
: subscriptionState === 'unknown'
? 'off'
: subscriptionState;
const enable = useMutation({
mutationFn: async () => {
const body = await subscribeToPush(publicKey);
if (body === null) {
// Разрешение могли не выдать: уточняем состояние у браузера.
setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off');
return false;
}
await subscribePush(body);
return true;
},
onSuccess: (subscribed) => {
if (subscribed) {
setSubscriptionState('on');
}
void queryClient.invalidateQueries({ queryKey: pushConfigQueryKey });
},
});
const disable = useMutation({
mutationFn: async () => {
const endpoint = await unsubscribeFromPush();
// Даже если браузер уже потерял подписку, сервер должен убрать запись.
await unsubscribePush(endpoint ?? undefined);
},
onSuccess: () => {
setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off');
},
});
const pushBusy = busy || enable.isPending || disable.isPending;
const togglePush = (): void => {
if (pushBusy) {
return;
}
setBusy(true);
if (availability === 'on') {
disable.mutate(undefined, { onSettled: () => setBusy(false) });
return;
}
enable.mutate(undefined, { onSettled: () => setBusy(false) });
};
return ( return (
<SettingsSection <SettingsSection
@@ -56,6 +166,60 @@ export function NotificationsSettingsSection() {
{t('settings.notifications.desktopSettings')} {t('settings.notifications.desktopSettings')}
</button> </button>
) : null} ) : null}
{platform.kind === 'tauri' ? null : (
<div className="mt-5 border-t border-border/40 pt-4" data-testid="push-settings">
<p className="text-sm font-medium">{t('settings.notifications.push')}</p>
<p className="mt-1 text-sm text-fg-muted">{t('settings.notifications.pushHint')}</p>
{availability === 'unsupported' ? (
<p className="mt-3 text-sm text-fg-muted" role="status">
{t('settings.notifications.pushUnsupported')}
</p>
) : config.isError ? (
<ErrorNotice
className="mt-3"
error={config.error}
onRetry={() => void config.refetch()}
/>
) : availability === 'unavailable' ? (
<p className="mt-3 text-sm text-fg-muted" role="status">
{t('settings.notifications.pushUnavailable')}
</p>
) : (
<div className="mt-3 flex flex-col gap-2">
<Button
disabled={pushBusy || !config.isSuccess || publicKey === ''}
onClick={togglePush}
data-testid="push-toggle"
>
{t(
pushBusy
? 'settings.notifications.pushWorking'
: availability === 'on'
? 'settings.notifications.pushDisable'
: 'settings.notifications.pushEnable',
)}
</Button>
{availability === 'on' ? (
<p className="text-xs text-success" role="status" data-testid="push-active">
{t('settings.notifications.pushActive')}
</p>
) : null}
{availability === 'denied' ? (
<p className="text-xs text-warning" role="status" data-testid="push-denied">
{t('settings.notifications.pushDenied')}
</p>
) : null}
{enable.isError ? (
<p className="text-xs text-danger" role="alert">
{t('settings.notifications.pushError')}
</p>
) : null}
</div>
)}
</div>
)}
</SettingsSection> </SettingsSection>
); );
} }
+190
View File
@@ -0,0 +1,190 @@
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<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 BrowserOptions {
subscription?: PushSubscription | null;
permission?: NotificationPermission;
requestPermission?: () => Promise<NotificationPermission>;
}
/** 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(
<QueryClientProvider client={client}>
<NotificationsSettingsSection />
</QueryClientProvider>,
);
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();
});
});
+202
View File
@@ -0,0 +1,202 @@
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;
}