191 lines
7.1 KiB
TypeScript
191 lines
7.1 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|