fix(mobile): replay cold-start notification taps after auth bootstrap
This commit is contained in:
parent
7a8e1e3e25
commit
9a5333c617
5 changed files with 396 additions and 22 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue