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, failReads: boolean, failWrites: boolean } { const data = new Map() 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 { 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 failFetch: boolean fetchRow: jest.Mock, [string]> insertRow: jest.Mock, [UserSettingsRowMutation]> updateRowAtRevision: jest.Mock, [UserSettingsRowMutation, number]> } function createFakeRemote(): FakeRemote { const rows = new Map() 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 => { 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 { 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((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((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') }) })