import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, type HostBridgeRequest, } from '../../../../packages/shared/src/contracts/hostBridge'; import { showMobileHostBridgeLocalNotification, showMobileLocalNotification, } from './notifications'; type NotificationPermissionStatus = Awaited< ReturnType >; const GRANTED_NOTIFICATION_PERMISSION = { status: 'granted', granted: true, canAskAgain: true, expires: 'never', } as NotificationPermissionStatus; const PROVISIONAL_NOTIFICATION_PERMISSION = { status: 'undetermined', granted: false, canAskAgain: true, expires: 'never', ios: { status: 'provisional', }, } as unknown as NotificationPermissionStatus; const DENIED_NOTIFICATION_PERMISSION = { status: 'denied', granted: false, canAskAgain: false, expires: 'never', } as NotificationPermissionStatus; vi.mock('expo-notifications', () => ({ AndroidImportance: { DEFAULT: 'default', }, IosAuthorizationStatus: { PROVISIONAL: 'provisional', }, getPermissionsAsync: vi.fn(), requestPermissionsAsync: vi.fn(), scheduleNotificationAsync: vi.fn(), setNotificationChannelAsync: vi.fn(), setNotificationHandler: vi.fn(), })); vi.mock('react-native', () => ({ Platform: { OS: 'ios', }, })); function request(payload?: unknown): HostBridgeRequest { return { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: 'notification-request', method: 'notification.showLocal', payload, }; } function setPlatformOS(os: 'ios' | 'android') { (Platform as { OS: 'ios' | 'android' }).OS = os; } beforeEach(() => { vi.mocked(Notifications.getPermissionsAsync).mockReset(); vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( GRANTED_NOTIFICATION_PERMISSION, ); vi.mocked(Notifications.requestPermissionsAsync).mockReset(); vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( GRANTED_NOTIFICATION_PERMISSION, ); vi.mocked(Notifications.scheduleNotificationAsync).mockReset(); vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue( 'notification-1', ); vi.mocked(Notifications.setNotificationChannelAsync).mockReset(); vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null); setPlatformOS('ios'); }); describe('mobile local notification helpers', () => { test('uses existing granted or provisional permission without prompting', async () => { await expect( showMobileLocalNotification({ title: '生成完成' }), ).resolves.toEqual( HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, ); expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1); expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce( PROVISIONAL_NOTIFICATION_PERMISSION, ); await expect( showMobileLocalNotification({ title: '生成完成' }), ).resolves.toEqual( HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, ); expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); }); test('requests alert-only permission when current permission is missing', async () => { vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( DENIED_NOTIFICATION_PERMISSION, ); vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( GRANTED_NOTIFICATION_PERMISSION, ); await expect( showMobileLocalNotification({ title: '生成完成' }), ).resolves.toEqual( HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, ); expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ ios: { allowAlert: true, allowBadge: false, allowSound: false, }, }); }); test('rejects delivery when permission remains denied', async () => { vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( DENIED_NOTIFICATION_PERMISSION, ); vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( DENIED_NOTIFICATION_PERMISSION, ); await expect( showMobileLocalNotification({ title: '生成完成' }), ).rejects.toMatchObject({ code: 'host_error', message: 'notification permission denied', }); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); }); test('rejects delivery when current permission lookup is unavailable', async () => { const error = new Error('native permission failed'); const warnSpy = vi .spyOn(console, 'warn') .mockImplementation(() => undefined); vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error); await expect( showMobileLocalNotification({ title: '生成完成' }), ).rejects.toMatchObject({ code: 'host_error', message: 'notification permission unavailable', }); expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( 'mobile notification failed for permission.current', ); warnSpy.mockRestore(); }); test('rejects delivery when permission request is unavailable', async () => { const error = new Error('native permission failed'); const warnSpy = vi .spyOn(console, 'warn') .mockImplementation(() => undefined); vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( DENIED_NOTIFICATION_PERMISSION, ); vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce( error, ); await expect( showMobileLocalNotification({ title: '生成完成' }), ).rejects.toMatchObject({ code: 'host_error', message: 'notification permission unavailable', }); expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( 'mobile notification failed for permission.request', ); warnSpy.mockRestore(); }); test('schedules iOS notification without channel trigger', async () => { await showMobileLocalNotification({ title: '生成完成', body: '作品已准备好', }); expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ content: { title: '生成完成', body: '作品已准备好', }, trigger: null, }); }); test('uses fixed Android local notification channel', async () => { setPlatformOS('android'); await showMobileLocalNotification({ title: '生成完成' }); expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, { name: 'Genarrative', importance: Notifications.AndroidImportance.DEFAULT, }, ); expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ content: { title: '生成完成', }, trigger: { channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, }, }); }); test('maps Android channel setup failures to stable delivery errors', async () => { const error = new Error('native channel failed'); const warnSpy = vi .spyOn(console, 'warn') .mockImplementation(() => undefined); setPlatformOS('android'); vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValueOnce( error, ); await expect( showMobileLocalNotification({ title: '生成完成' }), ).rejects.toMatchObject({ code: 'host_error', message: 'notification delivery unavailable', }); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( 'mobile notification failed for delivery.schedule', ); warnSpy.mockRestore(); }); test('maps native notification schedule failures to stable delivery errors', async () => { const error = new Error('native schedule failed'); const warnSpy = vi .spyOn(console, 'warn') .mockImplementation(() => undefined); vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce( error, ); await expect( showMobileLocalNotification({ title: '生成完成' }), ).rejects.toMatchObject({ code: 'host_error', message: 'notification delivery unavailable', }); expect(warnSpy).toHaveBeenCalledWith( 'mobile notification failed for delivery.schedule', ); warnSpy.mockRestore(); }); test('normalizes HostBridge payload and wraps structured delivery result', async () => { const response = await showMobileHostBridgeLocalNotification( request({ title: ' 生成完成 ', body: ' 作品已准备好 可以试玩 ', }), ); expect(response).toEqual({ bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: 'notification-request', ok: true, result: HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, }); expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({ content: { title: '生成完成', body: '作品已准备好 可以试玩', }, trigger: null, }); }); test('rejects invalid HostBridge notification payload before system calls', async () => { await expect( showMobileHostBridgeLocalNotification( request({ title: '生成\n完成', }), ), ).rejects.toMatchObject({ code: 'invalid_request', message: 'title is required', }); expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); }); });