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
+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;
}