jest.mock('../src/lib/supabase', () => ({ supabase: { rpc: jest.fn() }, })) jest.mock('../src/features/notifications/notification-native', () => ({ deleteNativePushRegistration: jest.fn(), getNativeNotificationStatus: jest.fn(), getNativePushRegistrationId: jest.fn(), })) const mockedSupabase = jest.requireMock('../src/lib/supabase').supabase as { rpc: jest.Mock } const mockedNative = jest.requireMock('../src/features/notifications/notification-native') as { deleteNativePushRegistration: jest.Mock getNativeNotificationStatus: jest.Mock getNativePushRegistrationId: jest.Mock } import type { RegisteredDevice } from '../src/features/devices/device-service' import { notificationNavigationTarget, parseNotificationPayload, NotificationContractError, } from '../src/features/notifications/notification-contract' import { detachPushRegistrationForLogout, registerPushRegistration, synchronizePushRegistration, } from '../src/features/notifications/notification-service' const USER_ID = '11111111-1111-4111-8111-111111111111' const DEVICE_ID = '22222222-2222-4222-8222-222222222222' const HISTORY_ID = '33333333-3333-4333-8333-333333333333' const TEAM_ID = '44444444-4444-4444-8444-444444444444' const INVITE_ID = '55555555-5555-4555-8555-555555555555' const SUBSCRIPTION_ID = '66666666-6666-4666-8666-666666666666' const INVITE_TOKEN = 'Abcdefghijklmnopqrstuvwxyz_12345' const REGISTRATION_ID = `fcm_${'a'.repeat(128)}` const device: RegisteredDevice = { id: DEVICE_ID, userId: USER_ID, installationId: '77777777-7777-4777-8777-777777777777', platform: 'android', deviceName: 'Pixel 7', appVersion: '1.0.0', osVersion: 'Android 14', pushToken: null, lastSeenAt: '2026-08-21T00:00:00.000Z', revokedAt: null, createdAt: '2026-08-21T00:00:00.000Z', updatedAt: '2026-08-21T00:00:00.000Z', } describe('notification payload allowlist', () => { test('maps transcription completion only to its matching history row', () => { const parsed = parseNotificationPayload({ schema_version: '1', event_type: 'transcription.completed', resource_id: HISTORY_ID, route: 'HistoryDetail', history_id: HISTORY_ID, }) expect(notificationNavigationTarget(parsed)).toEqual({ name: 'HistoryDetail', params: { historyId: HISTORY_ID }, }) }) test('maps invitation and billing events to fixed internal routes', () => { const invitation = parseNotificationPayload({ schema_version: '1', event_type: 'team.invite.created', resource_id: INVITE_ID, route: 'InviteAccept', team_id: TEAM_ID, invite_token: INVITE_TOKEN, }) expect(notificationNavigationTarget(invitation)).toEqual({ name: 'InviteAccept', params: { token: INVITE_TOKEN }, }) const billing = parseNotificationPayload({ schema_version: '1', event_type: 'billing.status.changed', resource_id: SUBSCRIPTION_ID, route: 'ProPaywall', subscription_id: SUBSCRIPTION_ID, }) expect(notificationNavigationTarget(billing)).toEqual({ name: 'ProPaywall' }) }) test('rejects caller-authored text, unknown routes, and cross-resource ids', () => { expect(() => parseNotificationPayload({ schema_version: '1', event_type: 'transcription.completed', resource_id: HISTORY_ID, route: 'HistoryDetail', history_id: HISTORY_ID, body: 'arbitrary phishing text', })).toThrow(NotificationContractError) expect(() => parseNotificationPayload({ schema_version: '1', event_type: 'transcription.completed', resource_id: HISTORY_ID, route: 'ExternalWebView', history_id: HISTORY_ID, })).toThrow('invalid-payload') expect(() => parseNotificationPayload({ schema_version: '1', event_type: 'transcription.completed', resource_id: HISTORY_ID, route: 'HistoryDetail', history_id: TEAM_ID, })).toThrow('invalid-payload') }) test('rejects unsupported event types instead of inventing a route', () => { expect(() => parseNotificationPayload({ schema_version: '1', event_type: 'meeting.comment.created', resource_id: HISTORY_ID, route: 'MeetingDetail', })).toThrow('unsupported-event') }) }) describe('device-bound push registration', () => { beforeEach(() => { mockedSupabase.rpc.mockReset() mockedNative.deleteNativePushRegistration.mockReset() mockedNative.getNativeNotificationStatus.mockReset() mockedNative.getNativePushRegistrationId.mockReset() }) test('does not fabricate or upload a token when Firebase config is absent', async () => { mockedNative.getNativeNotificationStatus.mockResolvedValue({ configured: false, provider: 'fcm', reason: 'firebase-config-missing', permission: 'granted', registrationId: null, fullSyncRequired: false, }) await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({ status: 'configuration-required', registeredAt: null, }) expect(mockedNative.getNativePushRegistrationId).not.toHaveBeenCalled() expect(mockedSupabase.rpc).not.toHaveBeenCalled() }) test('waits for Android notification permission before requesting a registration', async () => { mockedNative.getNativeNotificationStatus.mockResolvedValue({ configured: true, provider: 'fcm', reason: 'configured', permission: 'denied', registrationId: null, fullSyncRequired: false, }) await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({ status: 'permission-required', }) expect(mockedNative.getNativePushRegistrationId).not.toHaveBeenCalled() expect(mockedSupabase.rpc).not.toHaveBeenCalled() }) test('registers the real FCM identifier only through the device-owned RPC', async () => { mockedNative.getNativeNotificationStatus.mockResolvedValue({ configured: true, provider: 'fcm', reason: 'configured', permission: 'granted', registrationId: null, fullSyncRequired: false, }) mockedNative.getNativePushRegistrationId.mockResolvedValue(REGISTRATION_ID) mockedSupabase.rpc.mockResolvedValue({ data: { device_id: DEVICE_ID, provider: 'fcm', registered_at: '2026-08-21T00:00:00.000Z', }, error: null, }) await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({ status: 'registered', registeredAt: '2026-08-21T00:00:00.000Z', }) expect(mockedSupabase.rpc).toHaveBeenCalledWith('register_push_registration', { push_device_id: DEVICE_ID, push_provider: 'fcm', registration_id: REGISTRATION_ID, }) }) test('rejects revoked and cross-user devices before writing', async () => { await expect(registerPushRegistration( USER_ID, { ...device, revokedAt: '2026-08-21T00:00:00.000Z' }, REGISTRATION_ID, )).rejects.toMatchObject({ code: 'invalid-device' }) await expect(registerPushRegistration( '88888888-8888-4888-8888-888888888888', device, REGISTRATION_ID, )).rejects.toMatchObject({ code: 'invalid-device' }) expect(mockedSupabase.rpc).not.toHaveBeenCalled() }) test('detaches the server registration and deletes the native token before logout', async () => { mockedSupabase.rpc.mockResolvedValue({ data: { device_id: DEVICE_ID, removed: true }, error: null, }) mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined) await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined() expect(mockedSupabase.rpc).toHaveBeenCalledWith('unregister_push_registration', { push_device_id: DEVICE_ID, }) expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1) }) test('allows logout after native invalidation when server cleanup fails', async () => { mockedSupabase.rpc.mockResolvedValue({ data: null, error: { code: '503', message: 'network unavailable' }, }) mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined) await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined() expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1) }) test('allows logout after server cleanup when native invalidation fails', async () => { mockedSupabase.rpc.mockResolvedValue({ data: { device_id: DEVICE_ID, removed: true }, error: null, }) mockedNative.deleteNativePushRegistration.mockRejectedValue(new Error('firebase unavailable')) await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined() }) test('fails closed when both push cleanup boundaries fail', async () => { mockedSupabase.rpc.mockResolvedValue({ data: null, error: { code: '503', message: 'network unavailable' }, }) mockedNative.deleteNativePushRegistration.mockRejectedValue(new Error('firebase unavailable')) await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).rejects.toMatchObject({ code: 'native', }) }) test('invalidates the native token while device registration is unavailable', async () => { mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined) await expect(detachPushRegistrationForLogout(USER_ID, null)).resolves.toBeUndefined() expect(mockedSupabase.rpc).not.toHaveBeenCalled() expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1) }) })