import React from 'react' import { act, create } from 'react-test-renderer' import type { NotificationNavigationTarget } from '../src/features/notifications/notification-contract' import { canNavigateToNotificationTarget, createNotificationNavigationQueue, PENDING_NOTIFICATION_TARGET_TTL_MS, } from '../src/features/notifications/notification-navigation-queue' import { createNotificationOpenDedupe } from '../src/features/notifications/notification-open-dedupe' const mockGetInitialNativeNotification = jest.fn() let mockOpenedListener: ((payload: Record) => void) | null = null jest.mock('../src/lib/auth-context', () => ({ useAuth: () => ({ user: null }), })) jest.mock('../src/lib/device-context', () => ({ useDevice: () => ({ currentDevice: null }), })) jest.mock('../src/features/teams/pending-invite', () => ({ savePendingInviteToken: jest.fn(async () => undefined), })) jest.mock('../src/features/notifications/notification-service', () => ({ synchronizePushRegistration: jest.fn(), })) jest.mock('../src/features/notifications/notification-native', () => ({ clearNativeFullSyncRequired: jest.fn(async () => undefined), getInitialNativeNotification: (...args: unknown[]) => mockGetInitialNativeNotification(...args), getNativeNotificationStatus: jest.fn(async () => ({ fullSyncRequired: false })), onNotificationOpened: (listener: (payload: Record) => void) => { mockOpenedListener = listener return { remove: () => { mockOpenedListener = null } } }, onPushRegistrationChanged: () => ({ remove: () => undefined }), })) import { useNotificationRuntime } from '../src/features/notifications/use-notification-runtime' const HISTORY_ID = '11111111-1111-4111-8111-111111111111' const TRANSCRIPTION_PAYLOAD: Record = { schema_version: '1', event_type: 'transcription.completed', resource_id: HISTORY_ID, route: 'HistoryDetail', history_id: HISTORY_ID, } const HISTORY_TARGET: NotificationNavigationTarget = { name: 'HistoryDetail', params: { historyId: HISTORY_ID }, } const INVITE_TARGET: NotificationNavigationTarget = { name: 'InviteAccept', params: { token: 'invite-token-abcdefghijklmnop' }, } function Harness({ navigate, onError, }: { navigate: (target: NotificationNavigationTarget) => void onError?: (error: unknown) => void }): null { useNotificationRuntime({ navigate, onError }) return null } async function flushMicrotasks(): Promise { for (let index = 0; index < 5; index += 1) await Promise.resolve() } describe('notification navigation queue (cold start while auth bootstraps)', () => { it('holds an account target while signed out / bootstrapping and replays it once the app stack is ready', () => { const queue = createNotificationNavigationQueue() queue.offer(HISTORY_TARGET) // Cold start: container not mounted, then mounted but user still null. expect(queue.take({ navigationReady: false, authenticatedAppReady: false })).toBeNull() expect(queue.take({ navigationReady: true, authenticatedAppReady: false })).toBeNull() expect(queue.hasPending()).toBe(true) // Session restored: the target is delivered exactly once. expect(queue.take({ navigationReady: true, authenticatedAppReady: true })).toEqual(HISTORY_TARGET) expect(queue.take({ navigationReady: true, authenticatedAppReady: true })).toBeNull() }) it('lets invitation links through while signed out but never before navigation is ready', () => { expect(canNavigateToNotificationTarget(INVITE_TARGET, { navigationReady: true, authenticatedAppReady: false, })).toBe(true) expect(canNavigateToNotificationTarget(INVITE_TARGET, { navigationReady: false, authenticatedAppReady: true, })).toBe(false) expect(canNavigateToNotificationTarget(HISTORY_TARGET, { navigationReady: true, authenticatedAppReady: false, })).toBe(false) }) it('keeps only the latest target and expires stale taps', () => { let now = 1_000 const queue = createNotificationNavigationQueue({ now: () => now }) queue.offer(HISTORY_TARGET) queue.offer(INVITE_TARGET) expect(queue.take({ navigationReady: true, authenticatedAppReady: true })).toEqual(INVITE_TARGET) queue.offer(HISTORY_TARGET) now += PENDING_NOTIFICATION_TARGET_TTL_MS + 1 expect(queue.hasPending()).toBe(false) expect(queue.take({ navigationReady: true, authenticatedAppReady: true })).toBeNull() }) }) describe('notification open dedupe', () => { it('records a key only after delivery completes and releases it on abort', () => { const dedupe = createNotificationOpenDedupe() expect(dedupe.begin('a')).toBe(true) expect(dedupe.begin('a')).toBe(false) // in flight dedupe.abort('a') expect(dedupe.begin('a')).toBe(true) // failed attempt does not block retry dedupe.complete('a') expect(dedupe.begin('a')).toBe(false) }) it('bounds the handled set', () => { const dedupe = createNotificationOpenDedupe(2) for (const key of ['a', 'b', 'c']) { dedupe.begin(key) dedupe.complete(key) } expect(dedupe.begin('a')).toBe(true) expect(dedupe.begin('c')).toBe(false) }) }) describe('useNotificationRuntime one-shot initial notification', () => { beforeEach(() => { mockGetInitialNativeNotification.mockReset() mockOpenedListener = null }) it('delivers the one-shot initial payload even if navigate changed identity while it resolved', async () => { let resolveInitial: (payload: Record | null) => void = () => undefined mockGetInitialNativeNotification.mockImplementation(() => new Promise((resolve) => { resolveInitial = resolve })) const firstNavigate = jest.fn() const secondNavigate = jest.fn() let renderer: ReturnType await act(async () => { renderer = create() await flushMicrotasks() }) // Auth bootstrap commits the user: the caller's navigate callback changes. await act(async () => { renderer!.update() await flushMicrotasks() }) await act(async () => { resolveInitial(TRANSCRIPTION_PAYLOAD) await flushMicrotasks() }) expect(firstNavigate).not.toHaveBeenCalled() expect(secondNavigate).toHaveBeenCalledWith(HISTORY_TARGET) // The native module is one-shot; the hook must not re-read it per render. expect(mockGetInitialNativeNotification).toHaveBeenCalledTimes(1) }) it('does not mark a tap as handled when delivering it failed', async () => { mockGetInitialNativeNotification.mockResolvedValue(TRANSCRIPTION_PAYLOAD) const navigate = jest.fn() .mockImplementationOnce(() => { throw new Error('navigation_unavailable') }) const onError = jest.fn() await act(async () => { create() await flushMicrotasks() }) expect(onError).toHaveBeenCalledTimes(1) await act(async () => { mockOpenedListener?.(TRANSCRIPTION_PAYLOAD) await flushMicrotasks() }) expect(navigate).toHaveBeenCalledTimes(2) expect(navigate).toHaveBeenLastCalledWith(HISTORY_TARGET) // Once delivered, a duplicate report of the same tap is suppressed. await act(async () => { mockOpenedListener?.(TRANSCRIPTION_PAYLOAD) await flushMicrotasks() }) expect(navigate).toHaveBeenCalledTimes(2) }) })