diff --git a/tests/mobilePayments.receipt.test.ts b/tests/mobilePayments.receipt.test.ts new file mode 100644 index 0000000..7378600 --- /dev/null +++ b/tests/mobilePayments.receipt.test.ts @@ -0,0 +1,203 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as IAP from 'react-native-iap'; + +import { apiService } from '../src/services/api'; +import { mobilePaymentsService, PRODUCT_IDS } from '../src/services/mobilePayments'; + +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); +jest.mock('@react-native-async-storage/async-storage'); +jest.mock('react-native-iap'); +jest.mock('../src/services/api', () => ({ + apiService: { post: jest.fn() }, +})); +jest.mock('../src/utils/logger', () => ({ + appLogger: { + errorSync: jest.fn(), + warnSync: jest.fn(), + infoSync: jest.fn(), + debugSync: jest.fn(), + }, +})); +jest.mock('../src/store/deviceStore', () => ({ + useDeviceStore: { getState: () => ({ isDeviceCompromised: false }) }, +})); + +const mockStoreState = { + receiptValidationPending: false, + setReceiptValidationPending: jest.fn(), + setSubscriptionTier: jest.fn(), +}; +jest.mock('../src/store', () => ({ + useAppStore: { getState: jest.fn(() => mockStoreState) }, +})); + +const mockIAP = IAP as jest.Mocked; +const mockApi = apiService as jest.Mocked; +const mockStorage = AsyncStorage as jest.Mocked; + +const VALID_RECEIPT = 'valid_receipt_base64_abc123'; +const TAMPERED_RECEIPT = 'tampered_receipt_base64_xyz789'; +const EXPIRED_RECEIPT = 'expired_receipt_base64_def456'; +const REPLAYED_RECEIPT = 'replayed_receipt_base64_ghi012'; +const PRODUCT_ID = PRODUCT_IDS.PRO_MONTHLY; + +function makeServerResponse(valid: boolean, error?: string, extra?: Record) { + return { data: { valid, error, ...extra } }; +} + +describe('payment receipt tamper tests (#843)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStoreState.receiptValidationPending = false; + mockStorage.getItem.mockResolvedValue(null); + mockStorage.setItem.mockResolvedValue(undefined); + mockIAP.finishTransaction = jest.fn().mockResolvedValue(undefined); + }); + + // ── Valid receipt is accepted ───────────────────────────────────────── + + it('accepts a valid receipt and finishes the transaction', async () => { + mockApi.post.mockResolvedValueOnce( + makeServerResponse(true, undefined, { tier: 'pro', expiry: '2027-01-01T00:00:00Z' }) + ); + + const result = await mobilePaymentsService.validateReceipt(VALID_RECEIPT, 'ios', PRODUCT_ID); + + expect(result.valid).toBe(true); + expect(result.tier).toBe('pro'); + expect(mockApi.post).toHaveBeenCalledWith('/api/payments/validate-receipt', { + receipt: VALID_RECEIPT, + platform: 'ios', + productId: PRODUCT_ID, + }); + }); + + // ── Tampered receipt is rejected ────────────────────────────────────── + + it('rejects a tampered receipt with valid: false', async () => { + mockApi.post.mockResolvedValueOnce( + makeServerResponse(false, 'Receipt signature mismatch – possible tampering') + ); + + const result = await mobilePaymentsService.validateReceipt(TAMPERED_RECEIPT, 'ios', PRODUCT_ID); + + expect(result.valid).toBe(false); + expect(result.error).toContain('tamper'); + }); + + it('does not call finishTransaction when server rejects tampered receipt', async () => { + mockApi.post.mockResolvedValueOnce(makeServerResponse(false, 'Receipt tampered')); + + const result = await mobilePaymentsService.validateReceipt(TAMPERED_RECEIPT, 'ios', PRODUCT_ID); + + expect(result.valid).toBe(false); + // finishTransaction should only be called externally after valid:true + expect(mockIAP.finishTransaction).not.toHaveBeenCalled(); + }); + + // ── Expired receipt is rejected ─────────────────────────────────────── + + it('rejects an expired receipt', async () => { + mockApi.post.mockResolvedValueOnce(makeServerResponse(false, 'Receipt has expired')); + + const result = await mobilePaymentsService.validateReceipt(EXPIRED_RECEIPT, 'ios', PRODUCT_ID); + + expect(result.valid).toBe(false); + expect(result.error).toContain('expired'); + }); + + // ── Replayed receipt is rejected ────────────────────────────────────── + + it('rejects a replayed receipt (already redeemed)', async () => { + mockApi.post.mockResolvedValueOnce(makeServerResponse(false, 'Receipt already redeemed')); + + const result = await mobilePaymentsService.validateReceipt(REPLAYED_RECEIPT, 'ios', PRODUCT_ID); + + expect(result.valid).toBe(false); + expect(result.error).toContain('already redeemed'); + }); + + // ── Server error is thrown immediately (no retry) ───────────────────── + + it('throws immediately on server-returned error (4xx) without retrying', async () => { + const serverError = Object.assign(new Error('Forbidden'), { + response: { status: 403, data: { message: 'Forbidden' } }, + }); + mockApi.post.mockRejectedValueOnce(serverError); + + await expect( + mobilePaymentsService.validateReceipt(VALID_RECEIPT, 'ios', PRODUCT_ID) + ).rejects.toThrow('Forbidden'); + + expect(mockApi.post).toHaveBeenCalledTimes(1); + }); + + // ── Network error retries up to 4 attempts ─────────────────────────── + + it('retries on network error up to 4 total attempts', async () => { + const networkError = new Error('Network Error'); + mockApi.post.mockRejectedValue(networkError); + + jest.useFakeTimers(); + const promise = mobilePaymentsService + .validateReceipt(VALID_RECEIPT, 'ios', PRODUCT_ID) + .catch(e => e); + await jest.runAllTimersAsync(); + const error = await promise; + + expect(error.message).toBe('Network Error'); + expect(mockApi.post).toHaveBeenCalledTimes(4); + jest.useRealTimers(); + }); + + // ── Network error then success ──────────────────────────────────────── + + it('succeeds after transient network error on retry', async () => { + mockApi.post + .mockRejectedValueOnce(new Error('Network Error')) + .mockResolvedValueOnce(makeServerResponse(true, undefined, { tier: 'pro' })); + + jest.useFakeTimers(); + const promise = mobilePaymentsService.validateReceipt(VALID_RECEIPT, 'ios', PRODUCT_ID); + await jest.runAllTimersAsync(); + const result = await promise; + jest.useRealTimers(); + + expect(result.valid).toBe(true); + expect(mockApi.post).toHaveBeenCalledTimes(2); + }); + + // ── Receipt validation endpoint path is correct ─────────────────────── + + it('sends receipt to the correct validation endpoint', async () => { + mockApi.post.mockResolvedValueOnce(makeServerResponse(true)); + + await mobilePaymentsService.validateReceipt(VALID_RECEIPT, 'android', PRODUCT_ID); + + expect(mockApi.post).toHaveBeenCalledWith( + '/api/payments/validate-receipt', + expect.objectContaining({ + receipt: VALID_RECEIPT, + platform: 'android', + }) + ); + }); + + // ── Multiple receipts validated independently ────────────────────────── + + it('validates different receipts independently without cross-contamination', async () => { + mockApi.post + .mockResolvedValueOnce(makeServerResponse(true, undefined, { tier: 'pro' })) + .mockResolvedValueOnce(makeServerResponse(false, 'Invalid')); + + const result1 = await mobilePaymentsService.validateReceipt(VALID_RECEIPT, 'ios', PRODUCT_ID); + const result2 = await mobilePaymentsService.validateReceipt( + TAMPERED_RECEIPT, + 'ios', + PRODUCT_ID + ); + + expect(result1.valid).toBe(true); + expect(result2.valid).toBe(false); + }); +}); diff --git a/tests/requestQueue.e2e.test.ts b/tests/requestQueue.e2e.test.ts new file mode 100644 index 0000000..d08c982 --- /dev/null +++ b/tests/requestQueue.e2e.test.ts @@ -0,0 +1,237 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { InternalAxiosRequestConfig } from 'axios'; +import * as Network from 'expo-network'; + +import { requestQueue } from '../src/services/api/requestQueue'; +import * as secureStorage from '../src/services/secureStorage'; +import { useAppStore } from '../src/store'; + +jest.mock('../src/utils/logger', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + errorSync: jest.fn(), + warnSync: jest.fn(), + }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('../src/services/mobileAnalytics', () => ({ + mobileAnalyticsService: { trackEvent: jest.fn() }, +})); + +jest.mock('../src/services/secureStorage', () => ({ + isSessionValid: jest.fn(() => Promise.resolve(true)), + refreshAccessToken: jest.fn(), +})); + +jest.mock('../src/store', () => ({ + useAppStore: { + getState: jest.fn(() => ({ + logout: jest.fn(), + setTokens: jest.fn(), + })), + }, +})); + +const mockConfig = ( + overrides: Partial = {} +): InternalAxiosRequestConfig => + ({ + method: 'GET', + url: '/api/courses', + headers: {}, + data: undefined, + ...overrides, + }) as InternalAxiosRequestConfig; + +const mockStore: Record = {}; + +function setupAsyncStorageMock() { + (AsyncStorage.setItem as jest.Mock).mockImplementation((key: string, value: string) => { + mockStore[key] = value; + return Promise.resolve(); + }); + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => + Promise.resolve(mockStore[key] ?? null) + ); + (AsyncStorage.removeItem as jest.Mock).mockImplementation((key: string) => { + delete mockStore[key]; + return Promise.resolve(); + }); +} + +describe('requestQueue offline-to-online sync E2E (#840)', () => { + const mockIsSessionValid = secureStorage.isSessionValid as jest.MockedFunction< + typeof secureStorage.isSessionValid + >; + const mockRefresh = secureStorage.refreshAccessToken as jest.MockedFunction< + typeof secureStorage.refreshAccessToken + >; + const mockLogout = jest.fn(); + const mockSetTokens = jest.fn(); + + beforeEach(async () => { + Object.keys(mockStore).forEach(k => delete mockStore[k]); + setupAsyncStorageMock(); + mockLogout.mockReset(); + mockSetTokens.mockReset(); + mockIsSessionValid.mockResolvedValue(true); + mockRefresh.mockResolvedValue({ + accessToken: 'fresh-access-token', + refreshToken: 'fresh-refresh-token', + expiresAt: Date.now() + 3_600_000, + }); + (useAppStore.getState as jest.Mock).mockReturnValue({ + logout: mockLogout, + setTokens: mockSetTokens, + }); + + jest.spyOn(Network, 'getNetworkStateAsync').mockResolvedValue({ + isConnected: false, + isInternetReachable: false, + type: 'NONE', + } as any); + }); + + afterEach(async () => { + requestQueue.stopMonitoring(); + jest.restoreAllMocks(); + }); + + function goOnline() { + jest.spyOn(Network, 'getNetworkStateAsync').mockResolvedValue({ + isConnected: true, + isInternetReachable: true, + type: 'WIFI', + } as any); + } + + it('queues requests while offline, drains them in FIFO order after reconnect', async () => { + const callOrder: string[] = []; + const client = jest.fn().mockImplementation((cfg: InternalAxiosRequestConfig) => { + callOrder.push(cfg.url!); + return Promise.resolve({ data: 'ok' }); + }); + + const id1 = await requestQueue.addToQueue(mockConfig({ url: '/api/a', method: 'POST' })); + const id2 = await requestQueue.addToQueue(mockConfig({ url: '/api/b', method: 'POST' })); + const id3 = await requestQueue.addToQueue(mockConfig({ url: '/api/c', method: 'POST' })); + + expect(id1).toBeTruthy(); + expect(id2).toBeTruthy(); + expect(id3).toBeTruthy(); + + let queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(3); + + await requestQueue.processQueue(client); + expect(client).not.toHaveBeenCalled(); + + queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(3); + + goOnline(); + await requestQueue.processQueue(client); + + expect(client).toHaveBeenCalledTimes(3); + expect(callOrder).toEqual(['/api/a', '/api/b', '/api/c']); + + queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(0); + }); + + it('preserves FIFO within the same priority level', async () => { + const client = jest.fn().mockResolvedValue({ data: 'ok' }); + + await requestQueue.addToQueue(mockConfig({ url: '/first' }), 'normal'); + await requestQueue.addToQueue(mockConfig({ url: '/second' }), 'normal'); + await requestQueue.addToQueue(mockConfig({ url: '/third' }), 'normal'); + + goOnline(); + await requestQueue.processQueue(client); + + expect(client).toHaveBeenCalledTimes(3); + expect(client.mock.calls[0][0].url).toBe('/first'); + expect(client.mock.calls[1][0].url).toBe('/second'); + expect(client.mock.calls[2][0].url).toBe('/third'); + }); + + it('batches multiple PUT requests to the same endpoint into one call', async () => { + const client = jest.fn().mockResolvedValue({ data: 'ok' }); + + await requestQueue.addToQueue( + mockConfig({ method: 'PUT', url: '/api/profile', data: { name: 'a' } }) + ); + await requestQueue.addToQueue( + mockConfig({ method: 'PUT', url: '/api/profile', data: { name: 'b' } }) + ); + + goOnline(); + await requestQueue.processQueue(client); + + expect(client).toHaveBeenCalledTimes(1); + }); + + it('retries failed requests and eventually drops after max retries', async () => { + const client = jest.fn().mockRejectedValue(new Error('Server 500')); + + await requestQueue.addToQueue(mockConfig({ url: '/flaky' })); + goOnline(); + + await requestQueue.processQueue(client); + let queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(1); + expect(queue[0].retries).toBe(1); + + await requestQueue.processQueue(client); + queue = await requestQueue.getQueue(); + expect(queue[0].retries).toBe(2); + + await requestQueue.processQueue(client); + queue = await requestQueue.getQueue(); + expect(queue[0].retries).toBe(3); + + await requestQueue.processQueue(client); + queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(0); + }); + + it('processes critical requests before low-priority ones', async () => { + const callOrder: string[] = []; + const client = jest.fn().mockImplementation((cfg: InternalAxiosRequestConfig) => { + callOrder.push(cfg.url!); + return Promise.resolve({ data: 'ok' }); + }); + + await requestQueue.addToQueue(mockConfig({ url: '/low-1' }), 'low'); + await requestQueue.addToQueue(mockConfig({ url: '/critical-1' }), 'critical'); + await requestQueue.addToQueue(mockConfig({ url: '/normal-1' }), 'normal'); + + goOnline(); + await requestQueue.processQueue(client); + + expect(callOrder[0]).toBe('/critical-1'); + expect(callOrder[2]).toBe('/low-1'); + }); + + it('clears queue and logs out when session refresh fails', async () => { + mockIsSessionValid.mockResolvedValue(false); + mockRefresh.mockRejectedValue(new Error('Refresh failed')); + + const client = jest.fn().mockResolvedValue({ data: 'ok' }); + await requestQueue.addToQueue(mockConfig()); + + goOnline(); + await requestQueue.processQueue(client); + + expect(mockLogout).toHaveBeenCalledTimes(1); + expect(client).not.toHaveBeenCalled(); + + const queue = await requestQueue.getQueue(); + expect(queue).toHaveLength(0); + }); +}); diff --git a/tests/secureStorage.isolation.test.ts b/tests/secureStorage.isolation.test.ts new file mode 100644 index 0000000..ad512d3 --- /dev/null +++ b/tests/secureStorage.isolation.test.ts @@ -0,0 +1,195 @@ +import * as SecureStore from 'expo-secure-store'; + +import { + saveTokens, + getAccessToken, + getRefreshToken, + clearTokens, + saveUserData, + getUserData, + clearUserData, + initializeSecureStorage, + resetSecureStorage, +} from '../src/services/secureStorage'; + +jest.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 2, + getItemAsync: jest.fn(() => Promise.resolve(null)), + setItemAsync: jest.fn(() => Promise.resolve()), + deleteItemAsync: jest.fn(() => Promise.resolve()), +})); + +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../src/utils/logger', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +const mockSecureStore = SecureStore as jest.Mocked; + +function mockVerificationPass() { + let stored: Record = {}; + mockSecureStore.setItemAsync.mockImplementation((key: string, value: string) => { + stored[key] = value; + return Promise.resolve(); + }); + mockSecureStore.getItemAsync.mockImplementation((key: string) => + Promise.resolve(stored[key] ?? null) + ); + mockSecureStore.deleteItemAsync.mockImplementation((key: string) => { + delete stored[key]; + return Promise.resolve(); + }); +} + +describe('secureStorage key isolation (#842)', () => { + beforeEach(async () => { + jest.clearAllMocks(); + resetSecureStorage(); + mockVerificationPass(); + await initializeSecureStorage(); + }); + + // ── Key isolation: writing to key A, reading with key B returns null ──── + + it('returns null when reading a different key than what was written', async () => { + await saveTokens('token_A', 'refresh_A', Date.now() + 3600000); + + // Clear the mock call history so we can inspect fresh reads + mockSecureStore.getItemAsync.mockClear(); + + // Read a completely different key + mockSecureStore.getItemAsync.mockResolvedValue(null); + const result = await SecureStore.getItemAsync('teachlink_some_other_key', expect.any(Object)); + + expect(result).toBeNull(); + }); + + it('does not leak access token when reading refresh token key', async () => { + const realAccessToken = 'super-secret-access-token-12345'; + const realRefreshToken = 'super-secret-refresh-token-67890'; + + await saveTokens(realAccessToken, realRefreshToken, Date.now() + 3600000); + + // Verify getAccessToken only returns the access token value + mockSecureStore.getItemAsync.mockClear(); + const accessToken = await getAccessToken(); + expect(accessToken).toBe(realAccessToken); + + // Verify getRefreshToken only returns the refresh token value + mockSecureStore.getItemAsync.mockClear(); + const refreshToken = await getRefreshToken(); + expect(refreshToken).toBe(realRefreshToken); + + // Confirm each call used the correct key + const accessCalls = mockSecureStore.getItemAsync.mock.calls; + expect(accessCalls[0][0]).toBe('teachlink_refresh_token'); + }); + + // ── Deleted key returns null (not stale values) ──────────────────────── + + it('returns null after deleting a key instead of stale value', async () => { + await saveTokens('token_value', 'refresh_value', Date.now() + 3600000); + + // Confirm it's there + mockSecureStore.getItemAsync.mockClear(); + const before = await getAccessToken(); + expect(before).toBe('token_value'); + + // Delete tokens + await clearTokens(); + + // Confirm deleteItemAsync was called + expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith( + 'teachlink_access_token', + expect.any(Object) + ); + + // After deletion, getItemAsync should return null + mockSecureStore.getItemAsync.mockClear(); + mockSecureStore.getItemAsync.mockResolvedValue(null); + const after = await getAccessToken(); + expect(after).toBeNull(); + }); + + it('returns null for user data after clearing', async () => { + await saveUserData({ id: '123', name: 'Test' }); + + // Confirm data is there + mockSecureStore.getItemAsync.mockClear(); + const before = await getUserData(); + expect(before).toEqual({ id: '123', name: 'Test' }); + + // Clear + await clearUserData(); + + expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith( + 'teachlink_user_data', + expect.any(Object) + ); + + // After clearing, read returns null + mockSecureStore.getItemAsync.mockClear(); + mockSecureStore.getItemAsync.mockResolvedValue(null); + const after = await getUserData(); + expect(after).toBeNull(); + }); + + // ── Cross-key isolation for user data vs tokens ──────────────────────── + + it('does not mix user data with token data', async () => { + await saveTokens('access_val', 'refresh_val', Date.now() + 3600000); + await saveUserData({ id: 'user_42', name: 'Alice' }); + + mockSecureStore.getItemAsync.mockClear(); + const token = await getAccessToken(); + expect(token).toBe('access_val'); + + mockSecureStore.getItemAsync.mockClear(); + const userData = await getUserData(); + expect(userData).toEqual({ id: 'user_42', name: 'Alice' }); + + // Clear tokens should not affect user data + await clearTokens(); + + mockSecureStore.getItemAsync.mockClear(); + mockSecureStore.getItemAsync.mockImplementation((key: string) => { + const store: Record = { + teachlink_user_data: JSON.stringify({ id: 'user_42', name: 'Alice' }), + }; + return Promise.resolve(store[key] ?? null); + }); + + const userDataAfter = await getUserData(); + expect(userDataAfter).toEqual({ id: 'user_42', name: 'Alice' }); + }); + + // ── All deleteItemAsync calls use correct keys ───────────────────────── + + it('clearTokens deletes exactly the three token keys', async () => { + await saveTokens('a', 'b', Date.now()); + mockSecureStore.deleteItemAsync.mockClear(); + + await clearTokens(); + + const deletedKeys = mockSecureStore.deleteItemAsync.mock.calls.map(c => c[0]); + expect(deletedKeys).toContain('teachlink_access_token'); + expect(deletedKeys).toContain('teachlink_refresh_token'); + expect(deletedKeys).toContain('teachlink_session_expires_at'); + expect(deletedKeys).toHaveLength(3); + }); +}); diff --git a/tests/storeHydration.test.ts b/tests/storeHydration.test.ts new file mode 100644 index 0000000..cd8f0c7 --- /dev/null +++ b/tests/storeHydration.test.ts @@ -0,0 +1,106 @@ +import { StateCreator } from 'zustand'; + +import { createStore } from '../src/store/createStore'; + +const mmkvStore: Record = {}; + +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + getString: (key: string) => mmkvStore[key] ?? null, + set: (key: string, value: string) => { + mmkvStore[key] = value; + }, + delete: (key: string) => { + delete mmkvStore[key]; + }, + })), +})); + +jest.mock('../src/utils/logger', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + errorSync: jest.fn(), + warnSync: jest.fn(), + }, +})); + +jest.mock('zustand/middleware/immer', () => ({ + immer: (fn: StateCreator) => fn, +})); + +type CounterState = { + count: number; + increment: () => void; +}; + +function createCounterStore(persistedData?: string) { + Object.keys(mmkvStore).forEach(k => delete mmkvStore[k]); + if (persistedData !== undefined) { + mmkvStore['test-store'] = persistedData; + } + return createStore('test-store', set => ({ + count: 0, + increment: () => + set((state: CounterState) => { + state.count += 1; + }), + })); +} + +async function flushMicrotasks(count = 20) { + for (let i = 0; i < count; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +describe('store hydration edge cases (#841)', () => { + beforeEach(() => { + Object.keys(mmkvStore).forEach(k => delete mmkvStore[k]); + }); + + it('falls back to default state when storage is empty (fresh install)', async () => { + const store = createCounterStore(); + await flushMicrotasks(); + expect(store.getState().count).toBe(0); + }); + + it('falls back to default when persisted JSON is missing keys', async () => { + const store = createCounterStore(JSON.stringify({ state: {}, version: 0 })); + await flushMicrotasks(); + expect(store.getState().count).toBe(0); + }); + + it('hydrates correctly from valid persisted data', async () => { + const store = createCounterStore(JSON.stringify({ state: { count: 42 }, version: 0 })); + await flushMicrotasks(); + expect(store.getState().count).toBe(42); + }); + + it('falls back to default state when storage contains corrupt JSON', async () => { + const store = createCounterStore('<<>>not-json{{{'); + await flushMicrotasks(); + expect(store.getState().count).toBe(0); + }); + + it('handles unknown future version by falling back to default', async () => { + const store = createCounterStore(JSON.stringify({ state: { count: 7 }, version: 999 })); + await flushMicrotasks(); + expect(store.getState().count).toBe(0); + }); + + it('handles non-JSON string in storage gracefully', async () => { + const store = createCounterStore('not-valid-json-at-all'); + await flushMicrotasks(); + expect(store.getState().count).toBe(0); + }); + + it('handles empty string in storage', async () => { + const store = createCounterStore(''); + await flushMicrotasks(); + expect(store.getState().count).toBe(0); + }); +});