fix(mobile): replay cold-start notification taps after auth bootstrap

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 7a8e1e3e25
commit 9a5333c617
5 changed files with 396 additions and 22 deletions

View file

@ -0,0 +1,198 @@
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<string, string>) => 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<string, string>) => 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<string, string> = {
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<void> {
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<string, string> | null) => void = () => undefined
mockGetInitialNativeNotification.mockImplementation(() => new Promise((resolve) => {
resolveInitial = resolve
}))
const firstNavigate = jest.fn()
const secondNavigate = jest.fn()
let renderer: ReturnType<typeof create>
await act(async () => {
renderer = create(<Harness navigate={firstNavigate} />)
await flushMicrotasks()
})
// Auth bootstrap commits the user: the caller's navigate callback changes.
await act(async () => {
renderer!.update(<Harness navigate={secondNavigate} />)
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(<Harness navigate={navigate} onError={onError} />)
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)
})
})