d3ro-voice/apps/mobile-rn/__tests__/preferences-store.test.ts
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

356 lines
14 KiB
TypeScript

import {
createPreferencesStore,
preferencesOwnerFor,
getUserCacheKey,
INSTALLATION_CACHE_KEY,
type PreferencesRemote,
type PreferencesStorage,
type PreferencesStore,
type PreferencesStoreSnapshot,
type RemoteInsertResult,
type UserSettingsRowMutation,
} from '../src/lib/preferences-store'
const USER_A = 'user-a'
const USER_B = 'user-b'
const NOW = '2026-09-28T00:00:00.000Z'
function createMemoryStorage(): PreferencesStorage & { data: Map<string, string>, failReads: boolean, failWrites: boolean } {
const data = new Map<string, string>()
const storage = {
data,
failReads: false,
failWrites: false,
async multiGet(keys: readonly string[]) {
if (storage.failReads) throw new Error('read failed')
return keys.map((key) => [key, data.get(key) ?? null] as const)
},
async setItem(key: string, value: string) {
if (storage.failWrites) throw new Error('write failed')
data.set(key, value)
},
async multiSet(pairs: Array<[string, string]>) {
if (storage.failWrites) throw new Error('write failed')
for (const [key, value] of pairs) data.set(key, value)
},
async getAllKeys() {
return [...data.keys()]
},
async multiRemove(keys: readonly string[]) {
for (const key of keys) data.delete(key)
},
}
return storage
}
function row(userId: string, overrides: Partial<UserSettingsRowMutation> = {}): UserSettingsRowMutation {
return {
user_id: userId,
theme_mode: 'dark',
locale: 'en',
haptic_enabled: true,
auto_polish_enabled: true,
preferred_stt_model: null,
preferred_llm_model: null,
onboarding_version: 1,
tutorial_completed_at: null,
revision: 1,
...overrides,
}
}
interface FakeRemote extends PreferencesRemote {
rows: Map<string, UserSettingsRowMutation>
failFetch: boolean
fetchRow: jest.Mock<Promise<unknown>, [string]>
insertRow: jest.Mock<Promise<RemoteInsertResult>, [UserSettingsRowMutation]>
updateRowAtRevision: jest.Mock<Promise<unknown>, [UserSettingsRowMutation, number]>
}
function createFakeRemote(): FakeRemote {
const rows = new Map<string, UserSettingsRowMutation>()
const remote: FakeRemote = {
rows,
failFetch: false,
fetchRow: jest.fn(async (userId: string) => {
if (remote.failFetch) throw new Error('offline')
return rows.get(userId) ?? null
}),
insertRow: jest.fn(async (value: UserSettingsRowMutation): Promise<RemoteInsertResult> => {
if (rows.has(value.user_id)) return { status: 'duplicate' }
rows.set(value.user_id, value)
return { status: 'inserted', row: value }
}),
updateRowAtRevision: jest.fn(async (value: UserSettingsRowMutation, expected: number) => {
const current = rows.get(value.user_id)
if (current === undefined || current.revision !== expected) return null
rows.set(value.user_id, value)
return value
}),
}
return remote
}
async function settle(): Promise<void> {
for (let index = 0; index < 30; index += 1) await Promise.resolve()
}
function recordPhases(store: PreferencesStore): PreferencesStoreSnapshot[] {
const history: PreferencesStoreSnapshot[] = []
store.subscribe(() => history.push(store.getSnapshot()))
return history
}
describe('preferences store', () => {
test('first load of an owner enters initial-loading, then becomes ready', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A))
const store = createPreferencesStore({ storage, remote, now: () => NOW })
const history = recordPhases(store)
await store.load(preferencesOwnerFor(USER_A))
expect(history[0]?.phase).toBe('initial-loading')
expect(store.getSnapshot()).toMatchObject({
ownerKey: `user:${USER_A}`,
phase: 'ready',
syncStatus: 'synced',
errorCode: null,
lastSyncedAt: NOW,
})
expect(store.getSnapshot().preferences.themeMode).toBe('dark')
})
test('resync after an offline start never re-enters initial-loading', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'offline' })
expect(store.needsForegroundResync()).toBe(true)
const history = recordPhases(store)
await store.resync()
remote.failFetch = false
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'light', revision: 4 }))
await store.resync()
expect(history.length).toBeGreaterThan(0)
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
expect(store.getSnapshot().preferences.themeMode).toBe('light')
expect(store.needsForegroundResync()).toBe(false)
})
test('reloading the same ready owner is a background refresh', async () => {
const store = createPreferencesStore({
storage: createMemoryStorage(),
remote: createFakeRemote(),
now: () => NOW,
})
await store.load(preferencesOwnerFor(null))
const history = recordPhases(store)
await store.load(preferencesOwnerFor(null))
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
})
test('concurrent resync calls share one refresh', async () => {
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.fetchRow.mockClear()
await Promise.all([store.resync(), store.resync(), store.resync()])
expect(remote.fetchRow).toHaveBeenCalledTimes(1)
})
test('resync with a pending patch flushes it instead of reloading', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { revision: 2 }))
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.failFetch = true
const result = await store.commit({ hapticEnabled: false })
expect(result).toEqual({ localSaved: true, serverSynced: false })
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_FAILED' })
remote.failFetch = false
await store.resync()
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false, revision: 3 })
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
})
test('revision conflicts are retried against the latest row', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { revision: 5 }))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.updateRowAtRevision.mockImplementationOnce(async () => {
// Another device bumps the revision between our read and write.
remote.rows.set(USER_A, row(USER_A, { revision: 6, locale: 'ko' }))
return null
})
const result = await store.commit({ themeMode: 'light' })
expect(result.serverSynced).toBe(true)
expect(remote.rows.get(USER_A)).toMatchObject({ theme_mode: 'light', locale: 'ko', revision: 7 })
})
test('repeated revision conflicts surface SYNC_CONFLICT', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.updateRowAtRevision.mockImplementation(async () => null)
const result = await store.commit({ themeMode: 'light' })
expect(result.serverSynced).toBe(false)
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(3)
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_CONFLICT' })
})
test('an insert race (unique violation) falls back to the revision-checked update', async () => {
const remote = createFakeRemote()
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
remote.insertRow.mockImplementationOnce(async () => {
remote.rows.set(USER_A, row(USER_A, { revision: 3 }))
return { status: 'duplicate' }
})
await store.load(preferencesOwnerFor(USER_A))
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(1)
expect(remote.rows.get(USER_A)?.revision).toBe(4)
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
})
test('a stale server response is dropped after the owner switches', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
remote.rows.set(USER_B, row(USER_B, { theme_mode: 'light' }))
let releaseA: () => void = () => undefined
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
await new Promise<void>((resolve) => { releaseA = resolve })
return remote.rows.get(userId) ?? null
})
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
const loadA = store.load(preferencesOwnerFor(USER_A))
await settle()
await store.load(preferencesOwnerFor(USER_B))
releaseA()
await loadA
expect(store.getSnapshot()).toMatchObject({ ownerKey: `user:${USER_B}`, phase: 'ready' })
expect(store.getSnapshot().preferences.themeMode).toBe('light')
})
test('realtime rows are ignored while local edits are pending', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.failFetch = true
await store.commit({ themeMode: 'light' })
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'system', revision: 9 }))
expect(store.getSnapshot().preferences.themeMode).toBe('light')
store.applyRemote(USER_B, row(USER_B, { theme_mode: 'system' }))
expect(store.getSnapshot().preferences.themeMode).toBe('light')
})
test('realtime rows apply when nothing is pending', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
const storage = createMemoryStorage()
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'light', revision: 2 }))
await settle()
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'synced', phase: 'ready' })
expect(store.getSnapshot().preferences.themeMode).toBe('light')
expect(storage.data.get(getUserCacheKey(USER_A))).toContain('"themeMode":"light"')
})
test('cache read failure outranks the sync failure code', async () => {
const storage = createMemoryStorage()
storage.failReads = true
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_READ_FAILED' })
})
test('cache write failure outranks the sync failure code', async () => {
const storage = createMemoryStorage()
storage.failWrites = true
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_WRITE_FAILED' })
})
test('signed-out commits stay local and persist a pending installation patch', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(null))
const result = await store.commit({ locale: 'en' })
expect(result).toEqual({ localSaved: true, serverSynced: false })
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'local', ownerKey: 'installation' })
expect(storage.data.get(INSTALLATION_CACHE_KEY)).toContain('"pendingPatch":{"locale":"en"}')
expect(remote.fetchRow).not.toHaveBeenCalled()
expect(store.needsForegroundResync()).toBe(false)
})
test('a commit made during a background refresh stays pending on top of the server value', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark', revision: 2 }))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.failFetch = true
await store.resync() // -> offline, nothing pending
remote.failFetch = false
let releaseFetch: () => void = () => undefined
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
await new Promise<void>((resolve) => { releaseFetch = resolve })
return remote.rows.get(userId) ?? null
})
const refresh = store.resync()
await settle()
const commit = store.commit({ hapticEnabled: false })
await settle()
releaseFetch()
await refresh
const result = await commit
expect(result.serverSynced).toBe(true)
expect(store.getSnapshot().preferences.hapticEnabled).toBe(false)
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false })
expect(store.getSnapshot().phase).toBe('ready')
})
})