d3ro-voice/apps/mobile-rn/__tests__/preferences-resync-redteam-r3-18.test.tsx
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

148 lines
3.9 KiB
TypeScript

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<void>
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 : <Screen />
}
async function flushAsync(): Promise<void> {
for (let index = 0; index < 20; index += 1) {
await Promise.resolve()
}
await new Promise<void>((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<void> {
await act(async () => {
renderer = create(
<MobilePreferencesProvider>
<Gate />
</MobilePreferencesProvider>,
)
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<void> = Promise.resolve()
act(() => {
pending = retry()
})
await act(async () => {
await pending
await flushAsync()
})
expect(loadingHistory).not.toContain(true)
expect(screenMounts).toBe(1)
})
})