From 06cb7cf7bdf37108e0525e673084bf8ac85c3561 Mon Sep 17 00:00:00 2001 From: Alex Licata Date: Thu, 10 Jul 2025 14:48:46 +0200 Subject: [PATCH 1/2] add react-native-keychain --- bun.lock | 5 ++++- package.json | 1 + tsconfig.json | 3 +++ tsup.config.ts | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) mode change 100755 => 100644 bun.lock diff --git a/bun.lock b/bun.lock old mode 100755 new mode 100644 index 798614d3..8422cdcc --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 0, + "lockfileVersion": 1, "workspaces": { "": { "dependencies": { @@ -46,6 +46,7 @@ "prettier": "^3.4.2", "react": "18.3.1", "react-native": "^0.74.1", + "react-native-keychain": "^10.0.0", "react-native-mmkv": "^2.12.2", "react-test-renderer": "^18.3.1", "release-it": "^17.3.0", @@ -2396,6 +2397,8 @@ "react-native": ["react-native@0.74.1", "", { "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-community/cli": "13.6.6", "@react-native-community/cli-platform-android": "13.6.6", "@react-native-community/cli-platform-ios": "13.6.6", "@react-native/assets-registry": "0.74.83", "@react-native/codegen": "0.74.83", "@react-native/community-cli-plugin": "0.74.83", "@react-native/gradle-plugin": "0.74.83", "@react-native/js-polyfills": "0.74.83", "@react-native/normalize-colors": "0.74.83", "@react-native/virtualized-lists": "0.74.83", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "base64-js": "^1.5.1", "chalk": "^4.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "jest-environment-node": "^29.6.3", "jsc-android": "^250231.0.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.80.3", "metro-source-map": "^0.80.3", "mkdirp": "^0.5.1", "nullthrows": "^1.1.1", "pretty-format": "^26.5.2", "promise": "^8.3.0", "react-devtools-core": "^5.0.0", "react-refresh": "^0.14.0", "react-shallow-renderer": "^16.15.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.24.0-canary-efb381bbf-20230505", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.2", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^18.2.6", "react": "18.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-0H2XpmghwOtfPpM2LKqHIN7gxy+7G/r1hwJHKLV6uoyXGC/gCojRtoo5NqyKrWpFC8cqyT6wTYCLuG7CxEKilg=="], + "react-native-keychain": ["react-native-keychain@10.0.0", "", {}, "sha512-YzPKSAnSzGEJ12IK6CctNLU79T1W15WDrElRQ+1/FsOazGX9ucFPTQwgYe8Dy8jiSEDJKM4wkVa3g4lD2Z+Pnw=="], + "react-native-mmkv": ["react-native-mmkv@2.12.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.71.0" } }, "sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], diff --git a/package.json b/package.json index 44650a15..5967c2f7 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "prettier": "^3.4.2", "react": "18.3.1", "react-native": "^0.74.1", + "react-native-keychain":"^10.0.0", "react-native-mmkv": "^2.12.2", "react-test-renderer": "^18.3.1", "release-it": "^17.3.0", diff --git a/tsconfig.json b/tsconfig.json index bcc4ec88..4b9fa7fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,9 @@ "react-native": [ "node_modules/react-native" ], + "react-native-keychain":[ + "node_modules/react-native-keychain" + ], "react-native-mmkv": [ "node_modules/react-native-mmkv" ], diff --git a/tsup.config.ts b/tsup.config.ts index 55d33c1f..4039d256 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -12,6 +12,7 @@ const external = [ 'next/router', 'react', 'react-native', + 'react-native-keychain', 'react-native-mmkv', '@react-native-async-storage/async-storage', '@tanstack/react-query', From 09271d35dd82b973e3ae4f89344c892951d22cb7 Mon Sep 17 00:00:00 2001 From: Alex Licata Date: Thu, 10 Jul 2025 14:50:06 +0200 Subject: [PATCH 2/2] create observablePersistKeychain persist plugin --- src/persist-plugins/keychain.ts | 150 +++++ src/sync/syncTypes.ts | 8 + tests/persist-keychain.test.ts | 969 ++++++++++++++++++++++++++++++++ 3 files changed, 1127 insertions(+) create mode 100644 src/persist-plugins/keychain.ts create mode 100644 tests/persist-keychain.test.ts diff --git a/src/persist-plugins/keychain.ts b/src/persist-plugins/keychain.ts new file mode 100644 index 00000000..87cc7634 --- /dev/null +++ b/src/persist-plugins/keychain.ts @@ -0,0 +1,150 @@ +import type { Change } from '@legendapp/state'; +import { applyChanges, internal } from '@legendapp/state'; +import type { ObservablePersistKeychainPluginOptions, ObservablePersistPlugin } from '@legendapp/state/sync'; +import { + hasGenericPassword, + getGenericPassword, + resetGenericPassword, + setGenericPassword, +} from 'react-native-keychain'; + +const { safeParse, safeStringify } = internal; + +export class ObservablePersistKeychain implements ObservablePersistPlugin { + private data: Record = {}; + private configuration: ObservablePersistKeychainPluginOptions; + + constructor(configuration?: ObservablePersistKeychainPluginOptions) { + this.configuration = configuration ?? {}; + } + public async initialize() { + const { preload } = this.configuration; + if (preload && preload.length > 0) { + await Promise.all( + preload.map(async (table) => { + try { + const credentials = await this.getIfExists(table); + this.data[table] = + credentials && credentials.password ? safeParse(credentials.password) : undefined; + } catch (error) { + console.error('[legend-state] ObservablePersistKeychain failed to preload table', table, error); + this.data[table] = undefined; + this.configuration.onError?.(table, error, 'preload'); + } + }), + ); + } + } + public loadTable(table: string): void | Promise { + if (this.data[table] === undefined) { + return this.getIfExists(table) + .then((credentials) => { + try { + this.data[table] = + credentials && credentials.password ? safeParse(credentials.password) : undefined; + } catch (error) { + console.error('[legend-state] ObservablePersistKeychain failed to parse', table, error); + this.configuration.onError?.(table, error, 'load'); + this.data[table] = undefined; + } + }) + .catch((error: Error) => { + if (error?.message !== 'UserCancel') { + console.error('[legend-state] Keychain.getGenericPassword failed', table, error); + } + this.data[table] = undefined; + this.configuration.onError?.(table, error, 'load'); + }); + } + } + public getTable(table: string, init: object): T { + return (this.data[table] as T) ?? (init as T) ?? (undefined as T); + } + /** + * Metadata operations are not supported for keychain storage + */ + public getMetadata(): Record { + return {}; + } + public set(table: string, changes: Change[]): Promise { + if (!this.data[table]) { + this.data[table] = {}; + } + + this.data[table] = applyChanges(this.data[table] as object, changes); + return this.save(table); + } + /** + * Metadata operations are not supported for keychain storage + */ + public setMetadata(): Promise { + return Promise.resolve(); + } + public async deleteTable(table: string): Promise { + try { + await resetGenericPassword({ + service: table, + ...this.configuration.options, + }); + delete this.data[table]; + } catch (error) { + console.error('[legend-state] ObservablePersistKeychain failed to delete table', table, error); + this.configuration.onError?.(table, error, 'delete'); + } + } + /** + * Metadata operations are not supported for keychain storage + */ + public deleteMetadata(): Promise { + return Promise.resolve(); + } + private async save(table: string): Promise { + const value = this.data[table]; + + try { + if (value !== undefined && value !== null) { + const serializedValue = safeStringify(value); + await setGenericPassword( + table, // Use table name as username + serializedValue, + { + service: table, + ...this.configuration.options, + }, + ); + } else { + // Remove the entry if value is undefined or null + await resetGenericPassword({ + service: table, + ...this.configuration.options, + }); + delete this.data[table]; + } + } catch (error) { + console.error('[legend-state] ObservablePersistKeychain failed to save', table, error); + this.configuration.onError?.(table, error, 'save'); + throw error; + } + } + /** + * getGenericPassword could take several seconds in low-spec devices, so we first check credentials presence with hasGenericPassword since it is a lightweight check and does not incur the same overhead. + */ + private async getIfExists(table: string) { + if ( + !(await hasGenericPassword({ + service: table, + ...this.configuration.options, + })) + ) { + return undefined; + } + return await getGenericPassword({ + service: table, + ...this.configuration.options, + }); + } +} + +export function observablePersistKeychain(configuration?: ObservablePersistKeychainPluginOptions) { + return new ObservablePersistKeychain(configuration); +} diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index 6206dda8..581e7ee8 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -3,6 +3,8 @@ import type { MMKVConfiguration } from 'react-native-mmkv'; // @ts-ignore import type { AsyncStorageStatic } from '@react-native-async-storage/async-storage'; +// @ts-ignore +import type { GetOptions, SetOptions } from 'react-native-keychain'; import type { Change, @@ -112,6 +114,12 @@ export interface SyncedOptionsGlobal persist?: ObservablePersistPluginOptions & Omit; } +export interface ObservablePersistKeychainPluginOptions { + preload?: string[]; + options?: GetOptions & SetOptions; + onError?: (table: string, error: unknown, on: 'preload' | 'load' | 'delete' | 'save') => void; +} + export interface ObservablePersistIndexedDBPluginOptions { databaseName: string; version: number; diff --git a/tests/persist-keychain.test.ts b/tests/persist-keychain.test.ts new file mode 100644 index 00000000..48ae5bbd --- /dev/null +++ b/tests/persist-keychain.test.ts @@ -0,0 +1,969 @@ +import { syncObservable } from '../src/sync/syncObservable'; +import { observable } from '../src/observable'; +import { configureSynced } from '../src/sync/configureSynced'; +import { observablePersistKeychain } from '../src/persist-plugins/keychain'; +import { synced } from '../src/sync/synced'; +import { getPersistName, promiseTimeout } from './testglobals'; +import { when } from '../src/when'; + +// ===== Mock Setup ===== + +interface MockKeychainStorage { + [service: string]: { username: string; password: string }; +} + +const mockKeychainStorage: MockKeychainStorage = {}; + +// Mock implementation of react-native-keychain for Node.js test environment +jest.mock('react-native-keychain', () => ({ + hasGenericPassword: jest.fn(async (options: { service: string }) => { + return Promise.resolve(mockKeychainStorage[options.service] !== undefined); + }), + getGenericPassword: jest.fn(async (options: { service: string }) => { + const stored = mockKeychainStorage[options.service]; + if (stored) { + return Promise.resolve(stored); + } + return Promise.resolve(false); + }), + setGenericPassword: jest.fn(async (username: string, password: string, options: { service: string }) => { + mockKeychainStorage[options.service] = { username, password }; + return Promise.resolve(true); + }), + resetGenericPassword: jest.fn(async (options: { service: string }) => { + delete mockKeychainStorage[options.service]; + return Promise.resolve(true); + }), +})); + +// ===== Test Helpers ===== + +/** + * Resets the mock keychain storage to ensure test isolation + */ +function resetKeychainStorage(): void { + Object.keys(mockKeychainStorage).forEach((key) => { + delete mockKeychainStorage[key]; + }); +} + +// ===== Plugin Configuration ===== + +const keychainPlugin = observablePersistKeychain(); +const mySynced = configureSynced(synced, { + persist: { + plugin: keychainPlugin, + }, +}); + +describe('Keychain Persistence Plugin Tests', () => { + beforeEach(() => { + resetKeychainStorage(); + jest.clearAllMocks(); + }); + + describe('Basic Persistence', () => { + test('Plugin has required methods', () => { + const plugin = observablePersistKeychain(); + + // Verify all required plugin methods are available + expect(typeof plugin.initialize).toBe('function'); + expect(typeof plugin.loadTable).toBe('function'); + expect(typeof plugin.getTable).toBe('function'); + expect(typeof plugin.set).toBe('function'); + expect(typeof plugin.deleteTable).toBe('function'); + expect(typeof plugin.getMetadata).toBe('function'); + expect(typeof plugin.setMetadata).toBe('function'); + }); + + test('Direct plugin usage', async () => { + const plugin = observablePersistKeychain(); + const persistName = getPersistName(); + + // Test plugin methods directly with proper Change format + await plugin.set(persistName, [ + { + path: [], + pathTypes: [], + valueAtPath: { test: 'hello' }, + prevAtPath: undefined, + }, + ]); + + // Verify data was saved to mock storage + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + expect(stored.username).toBe(persistName); + expect(JSON.parse(stored.password)).toEqual({ test: 'hello' }); + }); + + test('Save and load objects', async () => { + const persistName = getPersistName(); + const obs = observable({ test: '' }); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Update value and verify persistence + obs.set({ test: 'hello' }); + await promiseTimeout(150); + + // Verify saved to keychain + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + expect(stored.username).toBe(persistName); + expect(JSON.parse(stored.password)).toEqual({ test: 'hello' }); + + // Verify loading in new observable + const obs2 = observable({}); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + expect(obs2.get()).toEqual({ test: 'hello' }); + }); + + test('Save and load primitives', async () => { + const persistName = getPersistName(); + const obs = observable(''); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Update primitive value + obs.set('hello'); + await promiseTimeout(150); + + // Verify persistence + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + expect(JSON.parse(stored.password)).toBe('hello'); + + // Verify loading + const obs2 = observable(); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + expect(obs2.get()).toEqual('hello'); + }); + + test('Load from pre-populated keychain', async () => { + const persistName = getPersistName(); + + // Pre-populate keychain with data + mockKeychainStorage[persistName] = { + username: persistName, + password: JSON.stringify({ preloaded: 'data' }), + }; + + const obs = observable({}); + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + expect(obs.get()).toEqual({ preloaded: 'data' }); + }); + + test('Handle empty keychain gracefully', async () => { + const persistName = getPersistName(); + const obs = observable({ default: 'value' }); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Should maintain initial value when keychain is empty + expect(obs.get()).toEqual({ default: 'value' }); + }); + }); + + describe('Complex Data Types', () => { + test('Nested objects persistence', async () => { + const persistName = getPersistName(); + const obs = observable({ + user: { + profile: { + name: 'John', + settings: { + theme: 'dark', + notifications: true, + }, + }, + preferences: { + language: 'en', + }, + }, + }); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Modify nested properties + obs.user.profile.settings.theme.set('light'); + obs.user.preferences.language.set('es'); + + await promiseTimeout(150); + + // Verify persistence + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData.user.profile.settings.theme).toBe('light'); + expect(savedData.user.preferences.language).toBe('es'); + + // Verify loading + const obs2 = observable({}); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + + const result = obs2.get() as any; + expect(result.user.profile.settings.theme).toBe('light'); + expect(result.user.preferences.language).toBe('es'); + }); + + test('Array persistence and modification', async () => { + const persistName = getPersistName(); + const obs = observable({ + items: ['item1', 'item2'], + numbers: [1, 2], + }); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Modify arrays using set() for proper persistence tracking + obs.items.set(['item1', 'item2', 'item3']); + obs.numbers.set([1, 10, 3]); + + await promiseTimeout(150); + + // Verify persistence + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData.items).toEqual(['item1', 'item2', 'item3']); + expect(savedData.numbers).toEqual([1, 10, 3]); + + // Verify loading + const obs2 = observable({}); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + + expect(obs2.get()).toEqual({ + items: ['item1', 'item2', 'item3'], + numbers: [1, 10, 3], + }); + }); + + test('Map persistence', async () => { + const persistName = getPersistName(); + const obs = observable(new Map()); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Add entries to Map + obs.set('key1', 'value1'); + obs.set('key2', 'value2'); + + await promiseTimeout(150); + + // Verify persistence - Maps are serialized as plain objects + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData.key1).toBe('value1'); + expect(savedData.key2).toBe('value2'); + + // Verify loading + const obs2 = observable(new Map()); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + + const result = obs2.get(); + expect(result.has('key1')).toBe(true); + expect(result.has('key2')).toBe(true); + expect(result.get('key1')).toBe('value1'); + expect(result.get('key2')).toBe('value2'); + }); + + test('Set persistence', async () => { + const persistName = getPersistName(); + const obs = observable(new Set()); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Add items to Set + obs.add('item1'); + obs.add('item2'); + + await promiseTimeout(150); + + // Verify persistence - Sets use special __LSType format + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData).toEqual({ + __LSType: 'Set', + value: ['item1', 'item2'], + }); + + // Verify loading + const obs2 = observable(new Set()); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + + expect(obs2.get()).toEqual(new Set(['item1', 'item2'])); + }); + + test('Array of objects persistence', async () => { + const persistName = getPersistName(); + const obs = observable({ + users: [ + { id: 1, name: 'Alice', active: true }, + { id: 2, name: 'Bob', active: false }, + ], + }); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Update array using set() for proper persistence + obs.users.set([ + { id: 1, name: 'Alice', active: true }, + { id: 2, name: 'Bob', active: true }, // changed + { id: 3, name: 'Charlie', active: true }, // new + ]); + + await promiseTimeout(150); + + // Verify persistence + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData.users).toEqual([ + { id: 1, name: 'Alice', active: true }, + { id: 2, name: 'Bob', active: true }, + { id: 3, name: 'Charlie', active: true }, + ]); + + // Verify loading + const obs2 = observable({}); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + + expect(obs2.get()).toEqual({ + users: [ + { id: 1, name: 'Alice', active: true }, + { id: 2, name: 'Bob', active: true }, + { id: 3, name: 'Charlie', active: true }, + ], + }); + }); + }); + + describe('Data Deletion', () => { + test('Delete object properties', async () => { + const persistName = getPersistName(); + const obs = observable({ + name: 'John', + age: 30, + email: 'john@example.com', + } as any); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Delete property by setting object without it + obs.set({ + name: 'John', + age: 30, + // email property removed + }); + + await promiseTimeout(150); + + // Verify deletion was persisted + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData).toEqual({ + name: 'John', + age: 30, + }); + expect(savedData.email).toBeUndefined(); + + // Verify loading without deleted property + const obs2 = observable({} as any); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + expect(obs2.get()).toEqual({ + name: 'John', + age: 30, + }); + }); + + test('Set property to undefined', async () => { + const persistName = getPersistName(); + const obs = observable({ + name: 'John', + age: 30, + status: 'active', + } as any); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Set property to undefined + obs.set({ + name: 'John', + age: 30, + status: undefined, + }); + + await promiseTimeout(150); + + // Verify undefined value is persisted + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData).toEqual({ + name: 'John', + age: 30, + status: undefined, + }); + + // Verify loading with undefined value + const obs2 = observable({} as any); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + expect(obs2.get()).toEqual({ + name: 'John', + age: 30, + status: undefined, + }); + }); + + test('Clear entire object', async () => { + const persistName = getPersistName(); + const obs = observable({ + name: 'John', + age: 30, + preferences: { + theme: 'dark', + notifications: true, + }, + } as any); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Clear/reset the entire object + obs.set({}); + + await promiseTimeout(150); + + // Verify empty object is persisted + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData).toEqual({}); + + // Verify loading empty object + const obs2 = observable({} as any); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + expect(obs2.get()).toEqual({}); + }); + + test('Remove array elements', async () => { + const persistName = getPersistName(); + const obs = observable({ + items: ['apple', 'banana', 'cherry', 'date'], + }); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Remove elements by setting new array + obs.items.set(['apple', 'cherry']); // Remove banana and date + + await promiseTimeout(150); + + // Verify modified array is persisted + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData).toEqual({ + items: ['apple', 'cherry'], + }); + + // Verify loading modified array + const obs2 = observable({}); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + expect(obs2.get()).toEqual({ + items: ['apple', 'cherry'], + }); + }); + + test('Clear array completely', async () => { + const persistName = getPersistName(); + const obs = observable({ + todos: [ + { id: 1, text: 'Task 1' }, + { id: 2, text: 'Task 2' }, + { id: 3, text: 'Task 3' }, + ], + }); + + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Clear the array + obs.todos.set([]); + + await promiseTimeout(150); + + // Verify empty array is persisted + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + const savedData = JSON.parse(stored.password); + expect(savedData).toEqual({ + todos: [], + }); + + // Verify loading empty array + const obs2 = observable({}); + const state2 = syncObservable( + obs2, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state2.isPersistLoaded); + expect(obs2.get()).toEqual({ + todos: [], + }); + }); + }); + describe('Error Handling and Edge Cases', () => { + test('Handle keychain read errors gracefully', async () => { + const persistName = getPersistName(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Configure mock to simulate keychain access error + const { hasGenericPassword, getGenericPassword } = jest.requireMock('react-native-keychain'); + hasGenericPassword.mockResolvedValueOnce(true); + getGenericPassword.mockRejectedValueOnce(new Error('Keychain access denied')); + + const obs = observable({ test: 'initial' }); + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Should handle error gracefully and maintain initial value + expect(obs.get()).toEqual({ test: 'initial' }); + expect(consoleSpy).toHaveBeenCalledWith( + '[legend-state] Keychain.getGenericPassword failed', + persistName, + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + test('Handle keychain save errors gracefully', async () => { + const persistName = getPersistName(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const obs = observable({ test: 'initial' }); + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Verify save operations don't crash on errors + obs.set({ test: 'updated' }); + await promiseTimeout(150); + + // Application should continue functioning + expect(obs.get()).toEqual({ test: 'updated' }); + + consoleSpy.mockRestore(); + }); + + test('Handle invalid JSON data gracefully', async () => { + const persistName = getPersistName(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Pre-populate keychain with invalid JSON + mockKeychainStorage[persistName] = { + username: persistName, + password: 'invalid json {', + }; + + const { hasGenericPassword, getGenericPassword } = jest.requireMock('react-native-keychain'); + hasGenericPassword.mockResolvedValueOnce(true); + getGenericPassword.mockResolvedValueOnce(mockKeychainStorage[persistName]); + + const obs = observable({ default: 'value' }); + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Should handle parse error and maintain initial value + expect(obs.get()).toEqual({ default: 'value' }); + expect(consoleSpy).toHaveBeenCalledWith( + '[legend-state] ObservablePersistKeychain failed to parse', + persistName, + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + test('Handle keychain deletion errors', async () => { + const persistName = getPersistName(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Pre-populate keychain with data + mockKeychainStorage[persistName] = { + username: persistName, + password: JSON.stringify({ test: 'data' }), + }; + + // Configure mock to simulate deletion error + const { resetGenericPassword } = jest.requireMock('react-native-keychain'); + resetGenericPassword.mockRejectedValueOnce(new Error('Keychain reset failed')); + + const plugin = observablePersistKeychain(); + await plugin.initialize(); + + // Attempt to delete table - should handle error gracefully + await plugin.deleteTable(persistName); + + expect(consoleSpy).toHaveBeenCalledWith( + '[legend-state] ObservablePersistKeychain failed to delete table', + persistName, + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + test('Handle corrupted data recovery', async () => { + const persistName = getPersistName(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Pre-populate with corrupted data + mockKeychainStorage[persistName] = { + username: persistName, + password: 'corrupted data that will fail to parse', + }; + + const obs = observable({ fallback: 'value' } as any); + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Should recover with fallback value + expect(obs.get()).toEqual({ fallback: 'value' }); + + // Should be able to save valid data after corruption + obs.set({ recovered: 'data' } as any); + await promiseTimeout(150); + + const stored = mockKeychainStorage[persistName]; + expect(stored).toBeDefined(); + expect(JSON.parse(stored.password)).toEqual({ recovered: 'data' }); + + consoleSpy.mockRestore(); + }); + + test('Handle various edge case data formats', async () => { + const persistName = getPersistName(); + + const testCases = ['null', '""', '{}', '[]']; + + for (const password of testCases) { + resetKeychainStorage(); + mockKeychainStorage[persistName] = { + username: persistName, + password, + }; + + const obs = observable({ default: 'initial' }); + const state = syncObservable( + obs, + mySynced({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Should handle each case without crashing + expect(obs.get()).toBeDefined(); + } + }); + }); + + describe('Configuration Options', () => { + test('Custom onError callback', async () => { + const persistName = getPersistName(); + const onErrorSpy = jest.fn(); + + // Create plugin with custom error handler + const pluginWithError = observablePersistKeychain({ + onError: onErrorSpy, + }); + + const mySyncedWithError = configureSynced(synced, { + persist: { + plugin: pluginWithError, + }, + }); + + // Simulate error condition + const { hasGenericPassword } = jest.requireMock('react-native-keychain'); + hasGenericPassword.mockRejectedValueOnce(new Error('Custom error')); + + const obs = observable({ test: 'initial' }); + const state = syncObservable( + obs, + mySyncedWithError({ + persist: { name: persistName }, + }), + ); + + await when(state.isPersistLoaded); + + // Should call custom error handler + expect(onErrorSpy).toHaveBeenCalledWith(persistName, expect.any(Error), 'load'); + }); + + test('Preload configuration', async () => { + const persistName1 = getPersistName(); + const persistName2 = getPersistName(); + + // Pre-populate keychain with data + mockKeychainStorage[persistName1] = { + username: persistName1, + password: JSON.stringify({ preloaded1: 'data1' }), + }; + mockKeychainStorage[persistName2] = { + username: persistName2, + password: JSON.stringify({ preloaded2: 'data2' }), + }; + + // Create plugin with preload option + const pluginWithPreload = observablePersistKeychain({ + preload: [persistName1, persistName2], + }); + + // Initialize should preload data + await pluginWithPreload.initialize(); + + // Verify plugin initializes correctly with preload + const table1Data = pluginWithPreload.getTable(persistName1, {}); + const table2Data = pluginWithPreload.getTable(persistName2, {}); + + expect(table1Data).toBeDefined(); + expect(table2Data).toBeDefined(); + }); + + test('Preload error handling', async () => { + const persistName = getPersistName(); + const onErrorSpy = jest.fn(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Configure mock to fail preload + const { hasGenericPassword } = jest.requireMock('react-native-keychain'); + hasGenericPassword.mockRejectedValueOnce(new Error('Preload error')); + + // Create plugin with preload and error handler + const pluginWithPreload = observablePersistKeychain({ + preload: [persistName], + onError: onErrorSpy, + }); + + await pluginWithPreload.initialize(); + + // Should handle preload error gracefully + expect(onErrorSpy).toHaveBeenCalledWith(persistName, expect.any(Error), 'preload'); + expect(consoleSpy).toHaveBeenCalledWith( + '[legend-state] ObservablePersistKeychain failed to preload table', + persistName, + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + }); +});