198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
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)
|
|
})
|
|
})
|