import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getNotificationEnableToastInfo, useBrowserNotifications, } from '../hooks/useBrowserNotifications'; import type { Message } from '../types'; const mocks = vi.hoisted(() => ({ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn(), }, })); vi.mock('../components/ui/sonner', () => ({ toast: mocks.toast, })); const incomingChannelMessage: Message = { id: 42, type: 'CHAN', conversation_key: 'ab'.repeat(16), text: 'hello room', sender_timestamp: 1700000000, received_at: 1700000001, paths: null, txt_type: 0, signature: null, sender_key: 'cd'.repeat(32), outgoing: false, acked: 0, sender_name: 'Alice', channel_name: '#flightless', }; describe('useBrowserNotifications', () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); window.location.hash = ''; vi.spyOn(window, 'open').mockReturnValue(null); Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden', }); vi.spyOn(document, 'hasFocus').mockReturnValue(false); const NotificationMock = vi.fn().mockImplementation(function (this: Record) { this.close = vi.fn(); this.onclick = null; }); Object.assign(NotificationMock, { permission: 'granted', requestPermission: vi.fn(async () => 'granted'), }); Object.defineProperty(window, 'Notification', { configurable: true, value: NotificationMock, }); Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true, }); }); it('stores notification opt-in per conversation', async () => { const { result } = renderHook(() => useBrowserNotifications()); await act(async () => { await result.current.toggleConversationNotifications( 'channel', incomingChannelMessage.conversation_key, '#flightless' ); }); expect( result.current.isConversationNotificationsEnabled( 'channel', incomingChannelMessage.conversation_key ) ).toBe(true); expect(result.current.isConversationNotificationsEnabled('contact', 'ef'.repeat(32))).toBe( false ); expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', { body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.', icon: './favicon-256x256.png', tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`, }); expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', { description: 'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.', }); }); it('only sends desktop notifications for opted-in conversations', async () => { const { result } = renderHook(() => useBrowserNotifications()); await act(async () => { await result.current.toggleConversationNotifications( 'channel', incomingChannelMessage.conversation_key, '#flightless' ); }); act(() => { result.current.notifyIncomingMessage(incomingChannelMessage); result.current.notifyIncomingMessage({ ...incomingChannelMessage, id: 43, conversation_key: '34'.repeat(16), channel_name: '#elsewhere', }); }); expect(window.Notification).toHaveBeenCalledTimes(2); expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', { body: 'hello room', icon: './favicon-256x256.png', tag: 'meshcore-message-42', }); }); it('notification click deep-links to the conversation hash', async () => { const focusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {}); const { result } = renderHook(() => useBrowserNotifications()); await act(async () => { await result.current.toggleConversationNotifications( 'channel', incomingChannelMessage.conversation_key, '#flightless' ); }); act(() => { result.current.notifyIncomingMessage(incomingChannelMessage); }); const notificationInstance = (window.Notification as unknown as ReturnType).mock .instances[1] as { onclick: (() => void) | null; close: ReturnType; }; act(() => { notificationInstance.onclick?.(); }); expect(window.open).toHaveBeenCalledWith( `${window.location.origin}${window.location.pathname}#channel/${incomingChannelMessage.conversation_key}/%23flightless`, '_self' ); expect(focusSpy).toHaveBeenCalledTimes(1); expect(notificationInstance.close).toHaveBeenCalledTimes(1); }); it('shows the browser guidance toast when notifications are blocked', async () => { Object.assign(window.Notification, { permission: 'denied', }); const { result } = renderHook(() => useBrowserNotifications()); await act(async () => { await result.current.toggleConversationNotifications( 'channel', incomingChannelMessage.conversation_key, '#flightless' ); }); expect(mocks.toast.error).toHaveBeenCalledWith('Notifications blocked', { description: 'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.', }); }); it('shows a warning toast when notifications are enabled on HTTP', async () => { const { result } = renderHook(() => useBrowserNotifications()); await act(async () => { await result.current.toggleConversationNotifications( 'channel', incomingChannelMessage.conversation_key, '#flightless' ); }); expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', { description: 'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.', }); expect(mocks.toast.success).not.toHaveBeenCalledWith('Notifications enabled'); }); it('best-effort detects insecure HTTPS for the enable-warning copy', () => { expect( getNotificationEnableToastInfo({ protocol: 'https:', isSecureContext: false, }) ).toEqual({ level: 'warning', title: 'Notifications enabled with warning', description: 'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.', }); }); it('shows a descriptive success toast when notifications are disabled', async () => { const { result } = renderHook(() => useBrowserNotifications()); await act(async () => { await result.current.toggleConversationNotifications( 'channel', incomingChannelMessage.conversation_key, '#flightless' ); }); await act(async () => { await result.current.toggleConversationNotifications( 'channel', incomingChannelMessage.conversation_key, '#flightless' ); }); expect(mocks.toast.success).toHaveBeenCalledWith('Notifications disabled', { description: 'Desktop notifications are off for #flightless.', }); }); });