import AsyncStorage from '@react-native-async-storage/async-storage' import { QueryClientProvider } from '@tanstack/react-query' import { renderHook, act } from '@testing-library/react-native' import React, { Suspense } from 'react' import { buildTestQueryClient } from '../../__utils__/query_client' import { useAsyncStorage } from '../../hooks/use_async_storage' jest.mock('@react-native-async-storage/async-storage') jest.useFakeTimers() afterAll(() => { jest.useRealTimers() }) const mockAsyncStorage = AsyncStorage as jest.Mocked const createWrapper = () => { const queryClient = buildTestQueryClient() return ({ children }: { children: React.ReactNode }) => ( {children} ) } // Helper to wait for Suspense and async operations to resolve const waitForQuery = async () => { await act(async () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() }) } describe('useAsyncStorage', () => { beforeEach(() => { jest.clearAllMocks() mockAsyncStorage.getItem.mockResolvedValue(null) mockAsyncStorage.setItem.mockResolvedValue(undefined) mockAsyncStorage.removeItem.mockResolvedValue(undefined) }) afterEach(() => { jest.restoreAllMocks() }) describe('initialization', () => { it('returns initial value when storage is empty', async () => { mockAsyncStorage.getItem.mockResolvedValue(null) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial-value'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial-value') }) it('returns parsed JSON value from storage', async () => { const storedValue = { foo: 'bar', count: 42 } mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedValue)) const { result } = renderHook(() => useAsyncStorage('test-key', { default: 'value' }), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toEqual(storedValue) }) it('returns raw string value when JSON parsing fails', async () => { mockAsyncStorage.getItem.mockResolvedValue('not-valid-json{') const { result } = renderHook(() => useAsyncStorage('test-key', 'initial-value'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('not-valid-json{') }) it('returns initial value when getItem throws an error', async () => { const error = new Error('Storage error') mockAsyncStorage.getItem.mockRejectedValue(error) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial-value'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial-value') }) it('returns initial value when getItem throws quota exceeded error', async () => { const quotaError = new Error('database or disk is full (code 13): SQLITE_FULL') mockAsyncStorage.getItem.mockRejectedValue(quotaError) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial-value'), { wrapper: createWrapper(), }) await act(async () => { await waitForQuery() }) expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial-value') }) }) describe('setValue', () => { it('saves value to storage and refetches', async () => { const { result } = renderHook(() => useAsyncStorage('test-key', 'initial'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial') const newValue = 'new-string-value' await act(async () => { await result.current[1](newValue) }) expect(mockAsyncStorage.setItem).toHaveBeenCalledWith('test-key', JSON.stringify(newValue)) }) it('saves object value to storage', async () => { const { result } = renderHook(() => useAsyncStorage('test-key', { initial: 'value' }), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() const newValue = { initial: 'new-value', data: 'test' } await act(async () => { await result.current[1](newValue) }) expect(mockAsyncStorage.setItem).toHaveBeenCalledWith('test-key', JSON.stringify(newValue)) }) it('removes item when value is null', async () => { mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify({ data: 'existing' })) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toEqual({ data: 'existing' }) await act(async () => { await result.current[1](null) }) expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith('test-key') expect(mockAsyncStorage.setItem).not.toHaveBeenCalled() }) it('removes item when value is undefined', async () => { const { result } = renderHook(() => useAsyncStorage('test-key', 'initial'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial') await act(async () => { await result.current[1](undefined) }) expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith('test-key') expect(mockAsyncStorage.setItem).not.toHaveBeenCalled() }) it('does not throw error when setItem throws quota exceeded error by default', async () => { const quotaError = new Error('database or disk is full (code 13): SQLITE_FULL') mockAsyncStorage.setItem.mockRejectedValue(quotaError) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial') // Should not throw - errors are caught by default let promiseResult: void | undefined await act(async () => { promiseResult = await result.current[1]('new-value') }) expect(promiseResult).toBeUndefined() }) it('propagates error when setItem throws quota exceeded error with throwOnError=true', async () => { const quotaError = new Error('database or disk is full (code 13): SQLITE_FULL') mockAsyncStorage.setItem.mockRejectedValue(quotaError) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial', true), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial') await act(async () => { await expect(result.current[1]('new-value')).rejects.toThrow('database or disk is full') }) }) it('does not throw error when setItem throws other errors by default', async () => { const error = new Error('Storage write failed') mockAsyncStorage.setItem.mockRejectedValue(error) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial') // Should not throw - errors are caught by default let promiseResult: void | undefined await act(async () => { promiseResult = await result.current[1]('new-value') }) expect(promiseResult).toBeUndefined() }) it('does not throw error when removeItem throws an error by default', async () => { const error = new Error('Remove failed') mockAsyncStorage.removeItem.mockRejectedValue(error) const { result } = renderHook(() => useAsyncStorage('test-key', 'initial'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('initial') // Should not throw - errors are caught by default (via noop) let promiseResult: void | undefined await act(async () => { promiseResult = await result.current[1](null) }) expect(promiseResult).toBeUndefined() }) }) describe('edge cases', () => { it('handles complex nested objects', async () => { const complexValue = { nested: { array: [1, 2, { deep: 'value' }], date: '2024-01-01', }, } mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(complexValue)) const { result } = renderHook(() => useAsyncStorage('test-key', {}), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toEqual(complexValue) }) it('uses correct cache key format', async () => { const { result } = renderHook(() => useAsyncStorage('my-unique-key', 'value'), { wrapper: createWrapper(), }) await waitForQuery() expect(result.current).toBeDefined() expect(result.current[0]).toBe('value') expect(mockAsyncStorage.getItem).toHaveBeenCalledWith('my-unique-key') }) }) })