import React, { useEffect } from 'react' import { AppState, type AppStateStatus } from 'react-native' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import AsyncStorage from '@react-native-async-storage/async-storage' const mockMaybeSingle = jest.fn(async () => ({ data: null, error: { message: 'network down', code: 'NETWORK' }, })) jest.mock('../src/lib/auth-context', () => ({ useAuth: () => ({ user: { id: '22222222-2222-4222-8222-222222222222' } }), })) jest.mock('../src/lib/supabase', () => { const channel = { on: () => channel, subscribe: () => channel, } const builder = { select: () => builder, eq: () => builder, maybeSingle: () => mockMaybeSingle(), } return { supabase: { from: () => builder, channel: () => channel, removeChannel: jest.fn(async () => 'ok'), }, } }) import { MobilePreferencesProvider, useMobilePreferences, } from '../src/lib/preferences-context' type RetrySync = () => Promise const loadingHistory: boolean[] = [] let screenMounts = 0 let latestRetrySync: RetrySync | null = null let latestSyncStatus: string | null = null function Screen(): React.ReactElement | null { useEffect(() => { screenMounts += 1 }, []) return null } function Gate(): React.ReactElement | null { const { loading, retrySync, syncStatus } = useMobilePreferences() loadingHistory.push(loading) latestRetrySync = retrySync latestSyncStatus = syncStatus // Mirrors App.tsx: while loading, the navigation tree is replaced by a boot screen. return loading ? null : } async function flushAsync(): Promise { for (let index = 0; index < 20; index += 1) { await Promise.resolve() } await new Promise((resolve) => setTimeout(resolve, 0)) } describe('mobile preferences background resync (redteam r3-18)', () => { let appStateListeners: Array<(state: AppStateStatus) => void> let renderer: ReactTestRenderer | null beforeEach(async () => { await AsyncStorage.clear() loadingHistory.length = 0 screenMounts = 0 latestRetrySync = null latestSyncStatus = null renderer = null appStateListeners = [] jest.spyOn(AppState, 'addEventListener').mockImplementation((type, listener) => { if (type === 'change') { appStateListeners.push(listener as (state: AppStateStatus) => void) } return { remove: () => { appStateListeners = appStateListeners.filter((entry) => entry !== listener) }, } }) }) afterEach(() => { act(() => { renderer?.unmount() }) jest.restoreAllMocks() }) async function renderOffline(): Promise { await act(async () => { renderer = create( , ) await flushAsync() }) expect(latestSyncStatus).toBe('offline') expect(loadingHistory[loadingHistory.length - 1]).toBe(false) expect(screenMounts).toBe(1) loadingHistory.length = 0 } test('foreground resync after an offline start never unmounts the app tree', async () => { await renderOffline() act(() => { for (const listener of appStateListeners) listener('active') }) await act(async () => { await flushAsync() }) expect(mockMaybeSingle.mock.calls.length).toBeGreaterThanOrEqual(2) expect(loadingHistory).not.toContain(true) expect(screenMounts).toBe(1) expect(latestSyncStatus).toBe('offline') }) test('Settings retry with no pending patch keeps the current screen mounted', async () => { await renderOffline() const retry = latestRetrySync if (retry === null) throw new Error('retrySync missing') let pending: Promise = Promise.resolve() act(() => { pending = retry() }) await act(async () => { await pending await flushAsync() }) expect(loadingHistory).not.toContain(true) expect(screenMounts).toBe(1) }) })