From 6de9584b7ae76df78a3800a329f14ba035fa9dd4 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 24 Jul 2026 12:25:38 +0200 Subject: [PATCH] test(mobile): add 18 unit and component test files to maximize test coverage --- .../tests/component/bulkActionBar.test.tsx | 122 +++++++ .../tests/component/comingSoonBadge.test.tsx | 43 +++ .../tests/component/noteBodyPreview.test.tsx | 44 +++ .../tests/component/settingsRow.test.tsx | 89 +++++ .../component/settingsSectionHeader.test.tsx | 33 ++ .../tests/component/snackbarToast.test.tsx | 87 +++++ .../tests/component/tagFilterBar.test.tsx | 46 +++ ui/mobile/tests/component/tagInput.test.tsx | 114 +++++++ .../tests/component/useNetworkStatus.test.tsx | 66 ++++ .../tests/component/useOfflineSync.test.tsx | 113 +++++++ ui/mobile/tests/unit/configAdapter.test.ts | 231 +++++++++++++ ui/mobile/tests/unit/enexExport.test.ts | 155 +++++++++ .../tests/unit/navigationAdapter.test.ts | 60 ++++ .../tests/unit/networkStatusAdapter.test.ts | 90 +++++ ui/mobile/tests/unit/oauthAdapter.test.ts | 87 +++++ ui/mobile/tests/unit/offlineStorage.test.ts | 310 ++++++++++++++++++ ui/mobile/tests/unit/searchHistory.test.ts | 158 +++++++++ ui/mobile/tests/unit/storageAdapter.test.ts | 187 +++++++++++ 18 files changed, 2035 insertions(+) create mode 100644 ui/mobile/tests/component/bulkActionBar.test.tsx create mode 100644 ui/mobile/tests/component/comingSoonBadge.test.tsx create mode 100644 ui/mobile/tests/component/noteBodyPreview.test.tsx create mode 100644 ui/mobile/tests/component/settingsRow.test.tsx create mode 100644 ui/mobile/tests/component/settingsSectionHeader.test.tsx create mode 100644 ui/mobile/tests/component/snackbarToast.test.tsx create mode 100644 ui/mobile/tests/component/tagFilterBar.test.tsx create mode 100644 ui/mobile/tests/component/tagInput.test.tsx create mode 100644 ui/mobile/tests/component/useNetworkStatus.test.tsx create mode 100644 ui/mobile/tests/component/useOfflineSync.test.tsx create mode 100644 ui/mobile/tests/unit/configAdapter.test.ts create mode 100644 ui/mobile/tests/unit/enexExport.test.ts create mode 100644 ui/mobile/tests/unit/navigationAdapter.test.ts create mode 100644 ui/mobile/tests/unit/networkStatusAdapter.test.ts create mode 100644 ui/mobile/tests/unit/oauthAdapter.test.ts create mode 100644 ui/mobile/tests/unit/offlineStorage.test.ts create mode 100644 ui/mobile/tests/unit/searchHistory.test.ts create mode 100644 ui/mobile/tests/unit/storageAdapter.test.ts diff --git a/ui/mobile/tests/component/bulkActionBar.test.tsx b/ui/mobile/tests/component/bulkActionBar.test.tsx new file mode 100644 index 00000000000..f0715cfd681 --- /dev/null +++ b/ui/mobile/tests/component/bulkActionBar.test.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { BulkActionBar } from '@ui/mobile/components/BulkActionBar' + +jest.mock('@ui/mobile/providers', () => ({ + useTheme: () => ({ + colors: { + background: '#ffffff', + border: '#e0e0e0', + primary: '#007aff', + mutedForeground: '#666666', + destructive: '#ff3b30', + destructiveForeground: '#ffffff', + }, + }), +})) + +describe('BulkActionBar', () => { + const defaultProps = { + selectedCount: 2, + totalCount: 5, + onSelectAll: jest.fn(), + onDeselectAll: jest.fn(), + onDelete: jest.fn(), + isPending: false, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders selected count and select all button text', () => { + render() + + expect(screen.getByText('2 selected')).toBeTruthy() + expect(screen.getByText('Select All (5)')).toBeTruthy() + }) + + it('triggers onSelectAll when select all button is pressed', () => { + const onSelectAll = jest.fn() + render() + + fireEvent.press(screen.getByText('Select All (5)')) + + expect(onSelectAll).toHaveBeenCalledTimes(1) + }) + + it('shows Deselect All and triggers onDeselectAll when all items are selected', () => { + const onDeselectAll = jest.fn() + render( + + ) + + expect(screen.getByText('Deselect All')).toBeTruthy() + expect(screen.getByText('5 selected')).toBeTruthy() + + fireEvent.press(screen.getByText('Deselect All')) + + expect(onDeselectAll).toHaveBeenCalledTimes(1) + }) + + it('disables delete button when selectedCount is 0', () => { + const onDelete = jest.fn() + render( + + ) + + const deleteButton = screen.getByRole('button', { name: 'Delete 0 notes' }) + expect(deleteButton.props.accessibilityState.disabled).toBe(true) + + fireEvent.press(deleteButton) + expect(onDelete).not.toHaveBeenCalled() + }) + + it('disables delete button when isPending is true', () => { + const onDelete = jest.fn() + render( + + ) + + const deleteButton = screen.getByRole('button', { name: 'Delete 2 notes' }) + expect(deleteButton.props.accessibilityState.disabled).toBe(true) + + fireEvent.press(deleteButton) + expect(onDelete).not.toHaveBeenCalled() + }) + + it('enables delete button and triggers onDelete when pressed with selectedCount > 0 and isPending is false', () => { + const onDelete = jest.fn() + render( + + ) + + const deleteButton = screen.getByRole('button', { name: 'Delete 3 notes' }) + expect(deleteButton.props.accessibilityState.disabled).toBe(false) + + fireEvent.press(deleteButton) + expect(onDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/mobile/tests/component/comingSoonBadge.test.tsx b/ui/mobile/tests/component/comingSoonBadge.test.tsx new file mode 100644 index 00000000000..327f5568cfc --- /dev/null +++ b/ui/mobile/tests/component/comingSoonBadge.test.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { View } from 'react-native' +import { render, screen, createMockTheme } from '../testUtils' +import { ComingSoonBadge } from '@ui/mobile/components/settings/ComingSoonBadge' + +const mockThemeColors = { + ...createMockTheme().colors, + muted: '#f5f5f5', + mutedForeground: '#777777', +} + +jest.mock('@ui/mobile/providers', () => ({ + useTheme: () => ({ + colors: mockThemeColors, + }), +})) + +describe('ComingSoonBadge component', () => { + it('renders "Soon" text inside the badge', () => { + render() + + expect(screen.getByText('Soon')).toBeTruthy() + }) + + it('applies colors.muted background style and colors.mutedForeground text style from useTheme', () => { + const { UNSAFE_getByType } = render() + + const textElement = screen.getByText('Soon') + expect(textElement.props.style).toEqual( + expect.objectContaining({ + color: '#777777', + fontSize: 11, + }) + ) + + const badgeContainer = UNSAFE_getByType(View) + expect(badgeContainer.props.style).toEqual( + expect.objectContaining({ + backgroundColor: '#f5f5f5', + }) + ) + }) +}) diff --git a/ui/mobile/tests/component/noteBodyPreview.test.tsx b/ui/mobile/tests/component/noteBodyPreview.test.tsx new file mode 100644 index 00000000000..f195dbb325d --- /dev/null +++ b/ui/mobile/tests/component/noteBodyPreview.test.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { View } from 'react-native' +import { render, screen } from '../testUtils' +import { NoteBodyPreview } from '@ui/mobile/components/NoteBodyPreview' +import { colors as themeColors } from '@ui/mobile/lib/theme' + +describe('NoteBodyPreview component', () => { + it('renders empty View container when HTML content parses to empty string or whitespace', () => { + const { UNSAFE_getByType, rerender } = render( + + ) + + expect(screen.queryByText(/./)).toBeNull() + const emptyView = UNSAFE_getByType(View) + expect(emptyView.props.style).toEqual( + expect.objectContaining({ + backgroundColor: themeColors.light.background, + }) + ) + + rerender() + expect(screen.queryByText(/./)).toBeNull() + }) + + it('renders converted plain text inside Text within ScrollView when HTML contains text', () => { + render() + + const textElement = screen.getByText('Hello world') + expect(textElement).toBeTruthy() + }) + + it('applies proper background color and text color based on colors prop', () => { + render() + + const textElement = screen.getByText('Test Content') + expect(textElement.props.style).toEqual( + expect.objectContaining({ + color: themeColors.light.foreground, + fontSize: 16, + lineHeight: 28, + }) + ) + }) +}) diff --git a/ui/mobile/tests/component/settingsRow.test.tsx b/ui/mobile/tests/component/settingsRow.test.tsx new file mode 100644 index 00000000000..112fa795dbc --- /dev/null +++ b/ui/mobile/tests/component/settingsRow.test.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { Text } from 'react-native' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { SettingsRow } from '@ui/mobile/components/settings/SettingsRow' + +jest.mock('@ui/mobile/providers', () => ({ + useTheme: () => ({ + colors: { + card: '#ffffff', + border: '#e0e0e0', + muted: '#f0f0f0', + foreground: '#000000', + mutedForeground: '#666666', + }, + }), +})) + +jest.mock('lucide-react-native', () => { + const React = require('react') + const { Text } = require('react-native') + return { + ChevronRight: () => React.createElement(Text, { testID: 'ChevronRight' }, 'ChevronRight'), + } +}) + +describe('SettingsRow', () => { + it('renders title and optional subtitle', () => { + render() + + expect(screen.getByText('Account')).toBeTruthy() + expect(screen.getByText('Manage profile')).toBeTruthy() + }) + + it('renders right node passed in props', () => { + render( + Enabled} + /> + ) + + expect(screen.getByTestId('badge')).toBeTruthy() + expect(screen.getByText('Enabled')).toBeTruthy() + }) + + it('invokes onPress when pressed and not disabled', () => { + const onPress = jest.fn() + render() + + fireEvent.press(screen.getByText('Clickable')) + + expect(onPress).toHaveBeenCalledTimes(1) + }) + + it('does not invoke onPress when disabled is true', () => { + const onPress = jest.fn() + render() + + fireEvent.press(screen.getByText('Disabled')) + + expect(onPress).not.toHaveBeenCalled() + }) + + it('renders chevron only when showChevron is true, onPress is provided, and disabled is false', () => { + const { rerender } = render() + expect(screen.queryByTestId('ChevronRight')).toBeTruthy() + + rerender() + expect(screen.queryByTestId('ChevronRight')).toBeNull() + + rerender() + expect(screen.queryByTestId('ChevronRight')).toBeNull() + + rerender() + expect(screen.queryByTestId('ChevronRight')).toBeNull() + }) + + it('applies top rounded corners for isFirst and bottom rounded corners for isLast', () => { + render() + + const row = screen.getByLabelText('Row') + const flatStyle = Object.assign({}, ...(Array.isArray(row.props.style) ? row.props.style : [row.props.style])) + + expect(flatStyle.borderTopLeftRadius).toBe(12) + expect(flatStyle.borderTopRightRadius).toBe(12) + expect(flatStyle.borderBottomLeftRadius).toBe(12) + expect(flatStyle.borderBottomRightRadius).toBe(12) + }) +}) diff --git a/ui/mobile/tests/component/settingsSectionHeader.test.tsx b/ui/mobile/tests/component/settingsSectionHeader.test.tsx new file mode 100644 index 00000000000..9f629295edc --- /dev/null +++ b/ui/mobile/tests/component/settingsSectionHeader.test.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import { SettingsSectionHeader } from '@ui/mobile/components/settings/SettingsSectionHeader' + +const mockColors = { + mutedForeground: '#6b7280', +} + +jest.mock('@ui/mobile/providers', () => ({ + useTheme: () => ({ + colors: mockColors, + }), +})) + +describe('SettingsSectionHeader', () => { + it('renders title string in uppercase format', () => { + render() + + expect(screen.getByText('ACCOUNT SETTINGS')).toBeTruthy() + }) + + it('applies colors.mutedForeground to text style', () => { + render() + + const textElement = screen.getByText('GENERAL') + + expect(textElement.props.style).toEqual( + expect.objectContaining({ + color: mockColors.mutedForeground, + }) + ) + }) +}) diff --git a/ui/mobile/tests/component/snackbarToast.test.tsx b/ui/mobile/tests/component/snackbarToast.test.tsx new file mode 100644 index 00000000000..a4a39283b45 --- /dev/null +++ b/ui/mobile/tests/component/snackbarToast.test.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { View } from 'react-native' +import { render, screen } from '@testing-library/react-native' +import type { ToastConfigParams } from 'react-native-toast-message' +import { SnackbarToast } from '@ui/mobile/components/SnackbarToast' + +const mockColors = { + foreground: '#1f2937', + background: '#ffffff', + destructive: '#ef4444', + destructiveForeground: '#fef2f2', +} + +jest.mock('@ui/mobile/providers', () => ({ + useTheme: () => ({ + colors: mockColors, + }), +})) + +describe('SnackbarToast', () => { + it('renders text1 message string passed in props', () => { + const props = { + text1: 'Note saved successfully', + type: 'success', + } as ToastConfigParams + + render() + + expect(screen.getByText('Note saved successfully')).toBeTruthy() + }) + + it('renders with default foreground background color and background text color when type !== "error"', () => { + const props = { + text1: 'Info notification', + type: 'info', + } as ToastConfigParams + + render() + + const textElement = screen.getByText('Info notification') + const viewElement = screen.UNSAFE_getByType(View) + + expect(textElement.props.style).toEqual( + expect.arrayContaining([{ color: mockColors.background }]) + ) + expect(viewElement.props.style).toEqual( + expect.arrayContaining([{ backgroundColor: mockColors.foreground }]) + ) + }) + + it('renders with destructive background color and destructiveForeground text color when type === "error"', () => { + const props = { + text1: 'Failed to save note', + type: 'error', + } as ToastConfigParams + + render() + + const textElement = screen.getByText('Failed to save note') + const viewElement = screen.UNSAFE_getByType(View) + + expect(textElement.props.style).toEqual( + expect.arrayContaining([{ color: mockColors.destructiveForeground }]) + ) + expect(viewElement.props.style).toEqual( + expect.arrayContaining([{ backgroundColor: mockColors.destructive }]) + ) + }) + + it('defaults to non-error colors when type is omitted or undefined', () => { + const props = { + text1: 'Default message', + } as ToastConfigParams + + render() + + const textElement = screen.getByText('Default message') + const viewElement = screen.UNSAFE_getByType(View) + + expect(textElement.props.style).toEqual( + expect.arrayContaining([{ color: mockColors.background }]) + ) + expect(viewElement.props.style).toEqual( + expect.arrayContaining([{ backgroundColor: mockColors.foreground }]) + ) + }) +}) diff --git a/ui/mobile/tests/component/tagFilterBar.test.tsx b/ui/mobile/tests/component/tagFilterBar.test.tsx new file mode 100644 index 00000000000..2f50be46e9a --- /dev/null +++ b/ui/mobile/tests/component/tagFilterBar.test.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react-native' +import { TagFilterBar } from '@ui/mobile/components/tags/TagFilterBar' + +jest.mock('@ui/mobile/providers', () => ({ + useTheme: () => ({ + colors: { + secondary: '#f0f0f0', + border: '#e0e0e0', + secondaryForeground: '#111111', + accent: '#dddddd', + destructive: '#cc0000', + foreground: '#000000', + }, + }), +})) + +jest.mock('lucide-react-native', () => ({ + Tag: 'Tag', + X: 'X', +})) + +describe('TagFilterBar component', () => { + it('returns null when tag prop is null', () => { + const { UNSAFE_root } = render() + expect(UNSAFE_root.children).toHaveLength(0) + expect(screen.queryByText('Clear Tags')).toBeNull() + }) + + it('renders TagChip with active tag string and Clear Tags button when tag is provided', () => { + render() + + expect(screen.getByText('projects')).toBeTruthy() + expect(screen.getByText('Clear Tags')).toBeTruthy() + }) + + it('triggers onClear callback when Clear Tags button is pressed', () => { + const onClearMock = jest.fn() + render() + + const clearButton = screen.getByText('Clear Tags') + fireEvent.press(clearButton) + + expect(onClearMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/mobile/tests/component/tagInput.test.tsx b/ui/mobile/tests/component/tagInput.test.tsx new file mode 100644 index 00000000000..98e3748b981 --- /dev/null +++ b/ui/mobile/tests/component/tagInput.test.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react-native' +import { TagInput } from '@ui/mobile/components/tags/TagInput' + +jest.mock('@ui/mobile/providers', () => ({ + useTheme: () => ({ + colors: { + primary: '#16a34a', + secondary: '#f3f4f6', + border: '#e5e7eb', + accent: '#e5e7eb', + mutedForeground: '#6b7280', + foreground: '#111827', + background: '#ffffff', + }, + }), +})) + +jest.mock('lucide-react-native', () => { + const React = require('react') + const { Text } = require('react-native') + return { + Plus: () => React.createElement(Text, null, 'PlusIcon'), + Tag: () => React.createElement(Text, null, 'TagIcon'), + X: () => React.createElement(Text, null, 'XIcon'), + } +}) + +describe('TagInput', () => { + it('renders label text, placeholder when tags is empty, and TagChips when tags is non-empty', () => { + const { rerender } = render( + + ) + + expect(screen.getByText('Custom Label')).toBeTruthy() + expect(screen.getByText('Custom placeholder...')).toBeTruthy() + + rerender( + + ) + + expect(screen.getByText('work')).toBeTruthy() + expect(screen.getByText('urgent')).toBeTruthy() + expect(screen.queryByText('Custom placeholder...')).toBeNull() + }) + + it('invokes onChangeTags without the target tag when tag removal is triggered', () => { + const onChangeTags = jest.fn() + render() + + fireEvent.press(screen.getByRole('button', { name: 'Remove tag work' }), { + stopPropagation: jest.fn(), + }) + + expect(onChangeTags).toHaveBeenCalledTimes(1) + expect(onChangeTags).toHaveBeenCalledWith(['personal']) + }) + + it('enters editing mode and displays text input when add button is clicked', () => { + render() + + expect(screen.queryByPlaceholderText('tag name')).toBeNull() + + fireEvent.press(screen.getByText('PlusIcon')) + + expect(screen.getByPlaceholderText('tag name')).toBeTruthy() + }) + + it('parses comma-separated tags, deduplicates, and calls onChangeTags on submit', () => { + const onChangeTags = jest.fn() + render() + + fireEvent.press(screen.getByText('PlusIcon')) + + const input = screen.getByPlaceholderText('tag name') + fireEvent.changeText(input, 'urgent, Work, ideas, URGENT ') + fireEvent(input, 'submitEditing') + + expect(onChangeTags).toHaveBeenCalledTimes(1) + expect(onChangeTags).toHaveBeenCalledWith(['work', 'urgent', 'ideas']) + }) + + it('parses draft text and calls onChangeTags on blur', () => { + const onChangeTags = jest.fn() + render() + + fireEvent.press(screen.getByText('PlusIcon')) + + const input = screen.getByPlaceholderText('tag name') + fireEvent.changeText(input, 'finance, tax') + fireEvent(input, 'blur') + + expect(onChangeTags).toHaveBeenCalledTimes(1) + expect(onChangeTags).toHaveBeenCalledWith(['finance', 'tax']) + }) + + it('does not enter editing mode when disabled is true', () => { + render() + + fireEvent.press(screen.getByText('PlusIcon')) + + expect(screen.queryByPlaceholderText('tag name')).toBeNull() + }) +}) diff --git a/ui/mobile/tests/component/useNetworkStatus.test.tsx b/ui/mobile/tests/component/useNetworkStatus.test.tsx new file mode 100644 index 00000000000..8998224daee --- /dev/null +++ b/ui/mobile/tests/component/useNetworkStatus.test.tsx @@ -0,0 +1,66 @@ +import { act, renderHook } from '../testUtils' +import { mobileNetworkStatusProvider } from '@ui/mobile/adapters/networkStatus' + +jest.unmock('@ui/mobile/hooks/useNetworkStatus') + +import { useNetworkStatus } from '@ui/mobile/hooks/useNetworkStatus' + +describe('hooks/useNetworkStatus', () => { + const mockIsOnline = mobileNetworkStatusProvider.isOnline as jest.Mock + const mockSubscribe = mobileNetworkStatusProvider.subscribe as jest.Mock + const mockUnsubscribe = jest.fn() + let currentCallback: ((online: boolean) => void) | null = null + + beforeEach(() => { + jest.clearAllMocks() + currentCallback = null + mockIsOnline.mockReturnValue(true) + mockSubscribe.mockImplementation((cb: (online: boolean) => void) => { + currentCallback = cb + return mockUnsubscribe + }) + }) + + it('returns initial online status as true when provider is online', () => { + mockIsOnline.mockReturnValue(true) + + const { result } = renderHook(() => useNetworkStatus()) + + expect(result.current).toBe(true) + expect(mockSubscribe).toHaveBeenCalledTimes(1) + }) + + it('returns initial online status as false when provider is offline', () => { + mockIsOnline.mockReturnValue(false) + + const { result } = renderHook(() => useNetworkStatus()) + + expect(result.current).toBe(false) + }) + + it('updates state when subscription callback triggers', () => { + const { result } = renderHook(() => useNetworkStatus()) + expect(result.current).toBe(true) + + act(() => { + currentCallback?.(false) + }) + expect(result.current).toBe(false) + + act(() => { + currentCallback?.(true) + }) + expect(result.current).toBe(true) + }) + + it('unsubscribes from provider on unmount', () => { + const { unmount } = renderHook(() => useNetworkStatus()) + + expect(mockSubscribe).toHaveBeenCalledTimes(1) + expect(mockUnsubscribe).not.toHaveBeenCalled() + + unmount() + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/mobile/tests/component/useOfflineSync.test.tsx b/ui/mobile/tests/component/useOfflineSync.test.tsx new file mode 100644 index 00000000000..ba0b4ca3418 --- /dev/null +++ b/ui/mobile/tests/component/useOfflineSync.test.tsx @@ -0,0 +1,113 @@ +import { AppState, type AppStateStatus } from 'react-native' +import { renderHook, act } from '@testing-library/react-native' +import { useOfflineSync } from '@ui/mobile/hooks/useOfflineSync' +import { useNetworkStatus } from '@ui/mobile/hooks/useNetworkStatus' +import { mobileSyncService } from '@ui/mobile/services/sync' + +jest.mock('@ui/mobile/hooks/useNetworkStatus', () => ({ + useNetworkStatus: jest.fn(), +})) + +jest.mock('@ui/mobile/services/sync', () => ({ + mobileSyncService: { + getManager: jest.fn(), + }, +})) + +describe('useOfflineSync', () => { + const mockUseNetworkStatus = useNetworkStatus as jest.Mock + const mockMobileSyncService = mobileSyncService as jest.Mocked + let appStateListener: ((status: AppStateStatus) => void) | null = null + const mockRemoveSubscription = jest.fn() + const mockDrainQueue = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + appStateListener = null + mockUseNetworkStatus.mockReturnValue(true) + mockMobileSyncService.getManager.mockReturnValue({ + drainQueue: mockDrainQueue, + } as unknown as ReturnType) + + jest.spyOn(AppState, 'addEventListener').mockImplementation((type: string, listener: unknown) => { + if (type === 'change') { + appStateListener = listener as (status: AppStateStatus) => void + } + return { remove: mockRemoveSubscription } as ReturnType + }) + }) + + it('subscribes to AppState change on mount and unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useOfflineSync()) + + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)) + expect(mockRemoveSubscription).not.toHaveBeenCalled() + + unmount() + + expect(mockRemoveSubscription).toHaveBeenCalledTimes(1) + }) + + it('calls mobileSyncService.getManager().drainQueue() when AppState becomes active and online', () => { + mockUseNetworkStatus.mockReturnValue(true) + renderHook(() => useOfflineSync()) + + expect(appStateListener).not.toBeNull() + + act(() => { + appStateListener?.('active') + }) + + expect(mockMobileSyncService.getManager).toHaveBeenCalledTimes(1) + expect(mockDrainQueue).toHaveBeenCalledTimes(1) + }) + + it('does nothing when AppState changes to non-active states (background or inactive)', () => { + mockUseNetworkStatus.mockReturnValue(true) + renderHook(() => useOfflineSync()) + + act(() => { + appStateListener?.('background') + }) + + expect(mockMobileSyncService.getManager).not.toHaveBeenCalled() + expect(mockDrainQueue).not.toHaveBeenCalled() + + act(() => { + appStateListener?.('inactive') + }) + + expect(mockMobileSyncService.getManager).not.toHaveBeenCalled() + expect(mockDrainQueue).not.toHaveBeenCalled() + }) + + it('does nothing when AppState becomes active but network is offline', () => { + mockUseNetworkStatus.mockReturnValue(false) + renderHook(() => useOfflineSync()) + + act(() => { + appStateListener?.('active') + }) + + expect(mockMobileSyncService.getManager).not.toHaveBeenCalled() + expect(mockDrainQueue).not.toHaveBeenCalled() + }) + + it('safely handles exceptions when mobileSyncService.getManager() throws an error', () => { + mockUseNetworkStatus.mockReturnValue(true) + mockMobileSyncService.getManager.mockImplementation(() => { + throw new Error('Sync manager not initialized') + }) + + renderHook(() => useOfflineSync()) + + expect(() => { + act(() => { + appStateListener?.('active') + }) + }).not.toThrow() + + expect(mockMobileSyncService.getManager).toHaveBeenCalledTimes(1) + expect(mockDrainQueue).not.toHaveBeenCalled() + }) +}) diff --git a/ui/mobile/tests/unit/configAdapter.test.ts b/ui/mobile/tests/unit/configAdapter.test.ts new file mode 100644 index 00000000000..1eaf99a240d --- /dev/null +++ b/ui/mobile/tests/unit/configAdapter.test.ts @@ -0,0 +1,231 @@ +import Constants from 'expo-constants' +import * as Linking from 'expo-linking' +import { + getSupabaseConfig, + getOAuthRedirectUrl, + getPublicWebOrigin, +} from '../../adapters/config' + +jest.mock('expo-constants', () => ({ + __esModule: true, + default: { + expoConfig: undefined, + }, +})) + +jest.mock('expo-linking', () => ({ + createURL: jest.fn((path: string) => `exp://127.0.0.1:8081/--/${path}`), +})) + +describe('config adapter', () => { + const ENV_KEYS = [ + 'EXPO_PUBLIC_SUPABASE_URL', + 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY', + 'EXPO_PUBLIC_SUPABASE_FUNCTIONS_URL', + 'EXPO_PUBLIC_OAUTH_REDIRECT_URL', + 'EXPO_PUBLIC_PUBLIC_WEB_ORIGIN', + 'EXPO_PUBLIC_EDITOR_WEBVIEW_URL', + ] + + beforeEach(() => { + jest.clearAllMocks() + for (const key of ENV_KEYS) { + delete process.env[key] + } + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = undefined + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + delete process.env[key] + } + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = undefined + }) + + describe('getSupabaseConfig', () => { + it('throws error when configuration is missing from both expoConfig and process.env', () => { + expect(() => getSupabaseConfig()).toThrow( + 'Missing Supabase configuration. Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY.' + ) + }) + + it('throws error when only supabaseUrl is provided but key is missing', () => { + process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co' + expect(() => getSupabaseConfig()).toThrow( + 'Missing Supabase configuration. Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY.' + ) + }) + + it('throws error when only key is provided but url is missing', () => { + process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY = 'anon-key-123' + expect(() => getSupabaseConfig()).toThrow( + 'Missing Supabase configuration. Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY.' + ) + }) + + it('returns config from Constants.expoConfig.extra when present', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + supabaseUrl: 'https://extra.supabase.co', + supabasePublishableKey: 'extra-anon-key', + supabaseFunctionsUrl: 'https://extra-fn.supabase.co', + }, + } + + const config = getSupabaseConfig() + + expect(config).toEqual({ + url: 'https://extra.supabase.co', + anonKey: 'extra-anon-key', + functionsUrl: 'https://extra-fn.supabase.co', + }) + }) + + it('falls back functionsUrl to url when supabaseFunctionsUrl is omitted in expoConfig.extra', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + supabaseUrl: 'https://extra.supabase.co', + supabasePublishableKey: 'extra-anon-key', + }, + } + + const config = getSupabaseConfig() + + expect(config).toEqual({ + url: 'https://extra.supabase.co', + anonKey: 'extra-anon-key', + functionsUrl: 'https://extra.supabase.co', + }) + }) + + it('returns config from process.env when expoConfig is empty', () => { + process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://env.supabase.co' + process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY = 'env-anon-key' + process.env.EXPO_PUBLIC_SUPABASE_FUNCTIONS_URL = 'https://env-fn.supabase.co' + + const config = getSupabaseConfig() + + expect(config).toEqual({ + url: 'https://env.supabase.co', + anonKey: 'env-anon-key', + functionsUrl: 'https://env-fn.supabase.co', + }) + }) + + it('falls back functionsUrl to url when EXPO_PUBLIC_SUPABASE_FUNCTIONS_URL is omitted in process.env', () => { + process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://env.supabase.co' + process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY = 'env-anon-key' + + const config = getSupabaseConfig() + + expect(config).toEqual({ + url: 'https://env.supabase.co', + anonKey: 'env-anon-key', + functionsUrl: 'https://env.supabase.co', + }) + }) + }) + + describe('getOAuthRedirectUrl', () => { + it('returns trimmed oauthRedirectUrl from Constants.expoConfig.extra', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + oauthRedirectUrl: ' myapp://oauth/callback ', + }, + } + + expect(getOAuthRedirectUrl()).toBe('myapp://oauth/callback') + }) + + it('returns trimmed oauthRedirectUrl from process.env when expoConfig is empty', () => { + process.env.EXPO_PUBLIC_OAUTH_REDIRECT_URL = 'envapp://callback' + + expect(getOAuthRedirectUrl()).toBe('envapp://callback') + }) + + it('falls back to scheme from expoConfig if oauthRedirectUrl is not configured', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + scheme: 'everfreenote-dev', + } + + expect(getOAuthRedirectUrl()).toBe('everfreenote-dev://auth/callback') + }) + + it('falls back to Linking.createURL when oauthRedirectUrl and scheme are missing', () => { + const url = getOAuthRedirectUrl() + + expect(Linking.createURL).toHaveBeenCalledWith('auth/callback') + expect(url).toBe('exp://127.0.0.1:8081/--/auth/callback') + }) + }) + + describe('getPublicWebOrigin', () => { + it('returns origin for valid http publicWebOrigin from expoConfig.extra', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + publicWebOrigin: 'https://app.everfreenote.com/notes?id=1', + }, + } + + expect(getPublicWebOrigin()).toBe('https://app.everfreenote.com') + }) + + it('returns origin for valid http publicWebOrigin from process.env', () => { + process.env.EXPO_PUBLIC_PUBLIC_WEB_ORIGIN = 'http://localhost:3000/app' + + expect(getPublicWebOrigin()).toBe('http://localhost:3000') + }) + + it('falls back to editorWebViewUrl when publicWebOrigin is an invalid URL', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + publicWebOrigin: 'not-a-valid-url', + editorWebViewUrl: 'https://editor.everfreenote.com/index.html', + }, + } + + expect(getPublicWebOrigin()).toBe('https://editor.everfreenote.com') + }) + + it('falls back to editorWebViewUrl when publicWebOrigin uses non-http/https protocol', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + publicWebOrigin: 'file:///local/file.html', + editorWebViewUrl: 'https://editor.everfreenote.com/index.html', + }, + } + + expect(getPublicWebOrigin()).toBe('https://editor.everfreenote.com') + }) + + it('returns origin from editorWebViewUrl in process.env when publicWebOrigin is empty', () => { + process.env.EXPO_PUBLIC_EDITOR_WEBVIEW_URL = 'http://192.168.0.10:8080/editor' + + expect(getPublicWebOrigin()).toBe('http://192.168.0.10:8080') + }) + + it('returns empty string when both publicWebOrigin and editorWebViewUrl are missing', () => { + expect(getPublicWebOrigin()).toBe('') + }) + + it('returns empty string when editorWebViewUrl is non-http protocol (e.g. android asset)', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + editorWebViewUrl: 'file:///android_asset/web-editor/index.html', + }, + } + + expect(getPublicWebOrigin()).toBe('') + }) + + it('returns empty string when editorWebViewUrl is invalid URL string', () => { + ;(Constants as unknown as { expoConfig?: unknown }).expoConfig = { + extra: { + editorWebViewUrl: 'invalid url', + }, + } + + expect(getPublicWebOrigin()).toBe('') + }) + }) +}) diff --git a/ui/mobile/tests/unit/enexExport.test.ts b/ui/mobile/tests/unit/enexExport.test.ts new file mode 100644 index 00000000000..bfb35c2c975 --- /dev/null +++ b/ui/mobile/tests/unit/enexExport.test.ts @@ -0,0 +1,155 @@ +const mockGetNotes = jest.fn() +const mockWriteAsStringAsync = jest.fn() +const mockBuild = jest.fn().mockReturnValue('') + +let mockDocumentDirectory: string | null = 'file:///documents/' +let mockCacheDirectory: string | null = 'file:///cache/' + +jest.mock('expo-file-system/legacy', () => ({ + __esModule: true, + get documentDirectory() { + return mockDocumentDirectory + }, + get cacheDirectory() { + return mockCacheDirectory + }, + EncodingType: { + UTF8: 'utf8', + }, + writeAsStringAsync: (...args: unknown[]) => mockWriteAsStringAsync(...args), +})) + +jest.mock('@core/services/notes', () => ({ + NoteService: jest.fn().mockImplementation(() => ({ + getNotes: (...args: unknown[]) => mockGetNotes(...args), + })), +})) + +jest.mock('@core/enex/enex-builder', () => ({ + EnexBuilder: jest.fn().mockImplementation(() => ({ + build: (...args: unknown[]) => mockBuild(...args), + })), +})) + +import { MobileEnexExportService } from '@ui/mobile/services/enexExport' + +describe('MobileEnexExportService', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDocumentDirectory = 'file:///documents/' + mockCacheDirectory = 'file:///cache/' + mockWriteAsStringAsync.mockResolvedValue(undefined) + mockBuild.mockReturnValue('Test') + }) + + it('exports notes successfully and calls progress callbacks for single page', async () => { + const mockNotes = [ + { id: '1', title: 'Note 1', content: 'Content 1' }, + { id: '2', title: 'Note 2', content: 'Content 2' }, + ] + mockGetNotes.mockResolvedValueOnce({ + notes: mockNotes, + totalCount: 2, + hasMore: false, + }) + + const onProgress = jest.fn() + const service = new MobileEnexExportService({} as never) + + const result = await service.exportAllNotes('user-123', onProgress) + + expect(mockGetNotes).toHaveBeenCalledTimes(1) + expect(mockGetNotes).toHaveBeenCalledWith('user-123', { page: 0, pageSize: 200 }) + + expect(onProgress).toHaveBeenNthCalledWith(1, { + stage: 'loading', + loaded: 2, + total: 2, + }) + expect(onProgress).toHaveBeenNthCalledWith(2, { + stage: 'building', + noteCount: 2, + }) + expect(onProgress).toHaveBeenNthCalledWith(3, { + stage: 'writing', + noteCount: 2, + fileName: expect.any(String), + }) + + expect(mockWriteAsStringAsync).toHaveBeenCalledWith( + expect.stringMatching(/^file:\/\/\/documents\//), + 'Test', + { encoding: 'utf8' } + ) + + expect(result).toEqual({ + fileUri: expect.stringMatching(/^file:\/\/\/documents\//), + fileName: expect.any(String), + noteCount: 2, + }) + }) + + it('paginates through multiple pages of notes', async () => { + const page1Notes = Array.from({ length: 200 }, (_, i) => ({ id: `id-${i}`, title: `Note ${i}` })) + const page2Notes = Array.from({ length: 50 }, (_, i) => ({ id: `id-${200 + i}`, title: `Note ${200 + i}` })) + + mockGetNotes + .mockResolvedValueOnce({ + notes: page1Notes, + totalCount: 250, + hasMore: true, + }) + .mockResolvedValueOnce({ + notes: page2Notes, + totalCount: 250, + hasMore: false, + }) + + const onProgress = jest.fn() + const service = new MobileEnexExportService({} as never) + + const result = await service.exportAllNotes('user-123', onProgress) + + expect(mockGetNotes).toHaveBeenCalledTimes(2) + expect(mockGetNotes).toHaveBeenNthCalledWith(1, 'user-123', { page: 0, pageSize: 200 }) + expect(mockGetNotes).toHaveBeenNthCalledWith(2, 'user-123', { page: 1, pageSize: 200 }) + + expect(onProgress).toHaveBeenCalledWith({ stage: 'loading', loaded: 200, total: 250 }) + expect(onProgress).toHaveBeenCalledWith({ stage: 'loading', loaded: 250, total: 250 }) + + expect(result.noteCount).toBe(250) + }) + + it('falls back to cacheDirectory when documentDirectory is unavailable', async () => { + mockDocumentDirectory = null + mockCacheDirectory = 'file:///cache/' + + mockGetNotes.mockResolvedValueOnce({ + notes: [], + totalCount: 0, + hasMore: false, + }) + + const service = new MobileEnexExportService({} as never) + const result = await service.exportAllNotes('user-123') + + expect(result.fileUri).toEqual(expect.stringMatching(/^file:\/\/\/cache\//)) + }) + + it('throws an error when base directory is completely unavailable', async () => { + mockDocumentDirectory = null + mockCacheDirectory = null + + mockGetNotes.mockResolvedValueOnce({ + notes: [], + totalCount: 0, + hasMore: false, + }) + + const service = new MobileEnexExportService({} as never) + + await expect(service.exportAllNotes('user-123')).rejects.toThrow( + 'Export directory is unavailable on this device' + ) + }) +}) diff --git a/ui/mobile/tests/unit/navigationAdapter.test.ts b/ui/mobile/tests/unit/navigationAdapter.test.ts new file mode 100644 index 00000000000..85a5951c00f --- /dev/null +++ b/ui/mobile/tests/unit/navigationAdapter.test.ts @@ -0,0 +1,60 @@ +import { router } from 'expo-router' +import { navigationAdapter } from '@ui/mobile/adapters/navigation' + +jest.mock('expo-router', () => ({ + router: { + push: jest.fn(), + replace: jest.fn(), + }, +})) + +describe('navigationAdapter', () => { + const mockRouter = router as jest.Mocked + + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('calls router.push(url) when options is omitted', () => { + void navigationAdapter.navigate('/notes/123') + expect(mockRouter.push).toHaveBeenCalledWith('/notes/123') + expect(mockRouter.replace).not.toHaveBeenCalled() + }) + + it('calls router.push(url) when options.replace is false', () => { + void navigationAdapter.navigate('/notes/123', { replace: false }) + expect(mockRouter.push).toHaveBeenCalledWith('/notes/123') + expect(mockRouter.replace).not.toHaveBeenCalled() + }) + + it('calls router.replace(url) when options.replace is true', () => { + void navigationAdapter.navigate('/login', { replace: true }) + expect(mockRouter.replace).toHaveBeenCalledWith('/login') + expect(mockRouter.push).not.toHaveBeenCalled() + }) + + it('logs error and re-throws when router.push throws', () => { + const error = new Error('Push failed') + mockRouter.push.mockImplementation(() => { + throw error + }) + + expect(() => navigationAdapter.navigate('/error-path')).toThrow(error) + expect(console.error).toHaveBeenCalledWith('[Navigation] Error navigating to:', '/error-path', error) + }) + + it('logs error and re-throws when router.replace throws', () => { + const error = new Error('Replace failed') + mockRouter.replace.mockImplementation(() => { + throw error + }) + + expect(() => navigationAdapter.navigate('/error-path', { replace: true })).toThrow(error) + expect(console.error).toHaveBeenCalledWith('[Navigation] Error navigating to:', '/error-path', error) + }) +}) diff --git a/ui/mobile/tests/unit/networkStatusAdapter.test.ts b/ui/mobile/tests/unit/networkStatusAdapter.test.ts new file mode 100644 index 00000000000..f4087617489 --- /dev/null +++ b/ui/mobile/tests/unit/networkStatusAdapter.test.ts @@ -0,0 +1,90 @@ +import NetInfo, { NetInfoState } from '@react-native-community/netinfo' + +jest.unmock('@ui/mobile/adapters/networkStatus') + +jest.mock('@react-native-community/netinfo', () => ({ + fetch: jest.fn(() => Promise.resolve({ isConnected: true })), + addEventListener: jest.fn(() => jest.fn()), +})) + +import { + MobileNetworkStatusProvider, + mobileNetworkStatusProvider, +} from '@ui/mobile/adapters/networkStatus' + +describe('MobileNetworkStatusProvider', () => { + const mockNetInfoFetch = NetInfo.fetch as jest.Mock + const mockAddEventListener = NetInfo.addEventListener as jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + mockNetInfoFetch.mockResolvedValue({ isConnected: true } as NetInfoState) + mockAddEventListener.mockReturnValue(jest.fn()) + }) + + it('defaults isOnline to true initially', () => { + mockNetInfoFetch.mockReturnValue(new Promise(() => {})) + const provider = new MobileNetworkStatusProvider() + expect(provider.isOnline()).toBe(true) + }) + + it('updates isOnline based on initial NetInfo.fetch resolution', async () => { + let resolveFetch!: (state: Partial) => void + const fetchPromise = new Promise>((resolve) => { + resolveFetch = resolve + }) + mockNetInfoFetch.mockReturnValue(fetchPromise) + + const provider = new MobileNetworkStatusProvider() + expect(provider.isOnline()).toBe(true) + + resolveFetch({ isConnected: false }) + await fetchPromise + + expect(provider.isOnline()).toBe(false) + }) + + it('handles null or undefined isConnected property from NetInfo', async () => { + mockNetInfoFetch.mockResolvedValue({ isConnected: null } as unknown as NetInfoState) + const provider = new MobileNetworkStatusProvider() + + await Promise.resolve() + + expect(provider.isOnline()).toBe(false) + }) + + it('subscribes to NetInfo changes and invokes callback with updated online status', () => { + type NetInfoListener = (state: Partial) => void + let listener!: NetInfoListener + const unsubscribeMock = jest.fn() + + mockAddEventListener.mockImplementation((cb: NetInfoListener) => { + listener = cb + return unsubscribeMock + }) + + const provider = new MobileNetworkStatusProvider() + const callback = jest.fn() + + const unsubscribe = provider.subscribe(callback) + + expect(mockAddEventListener).toHaveBeenCalledTimes(1) + expect(unsubscribe).toBe(unsubscribeMock) + + listener({ isConnected: false }) + + expect(provider.isOnline()).toBe(false) + expect(callback).toHaveBeenCalledWith(false) + + listener({ isConnected: true }) + + expect(provider.isOnline()).toBe(true) + expect(callback).toHaveBeenCalledWith(true) + }) + + it('exports a singleton instance mobileNetworkStatusProvider', () => { + expect(mobileNetworkStatusProvider).toBeInstanceOf(MobileNetworkStatusProvider) + expect(typeof mobileNetworkStatusProvider.isOnline).toBe('function') + expect(typeof mobileNetworkStatusProvider.subscribe).toBe('function') + }) +}) diff --git a/ui/mobile/tests/unit/oauthAdapter.test.ts b/ui/mobile/tests/unit/oauthAdapter.test.ts new file mode 100644 index 00000000000..677ba256b61 --- /dev/null +++ b/ui/mobile/tests/unit/oauthAdapter.test.ts @@ -0,0 +1,87 @@ +import * as WebBrowser from 'expo-web-browser' +import { getOAuthRedirectUrl } from '../../adapters/config' +import { oauthAdapter } from '../../adapters/oauth' + +jest.mock('expo-web-browser', () => ({ + warmUpAsync: jest.fn().mockResolvedValue(undefined), + openAuthSessionAsync: jest.fn().mockResolvedValue({ type: 'success', url: 'app://callback' }), + coolDownAsync: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock('../../adapters/config', () => ({ + getOAuthRedirectUrl: jest.fn().mockReturnValue('everfreenote-dev://auth/callback'), +})) + +describe('oauthAdapter', () => { + const mockWarmUp = WebBrowser.warmUpAsync as jest.Mock + const mockOpenAuthSession = WebBrowser.openAuthSessionAsync as jest.Mock + const mockCoolDown = WebBrowser.coolDownAsync as jest.Mock + const mockGetOAuthRedirectUrl = getOAuthRedirectUrl as jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + mockGetOAuthRedirectUrl.mockReturnValue('everfreenote-dev://auth/callback') + mockWarmUp.mockResolvedValue(undefined) + mockOpenAuthSession.mockResolvedValue({ type: 'success', url: 'everfreenote-dev://auth/callback?code=abc' }) + mockCoolDown.mockResolvedValue(undefined) + }) + + describe('startOAuth', () => { + it('executes warmUp, openAuthSessionAsync, and coolDown with correct arguments on success', async () => { + const authUrl = 'https://example.supabase.co/auth/v1/authorize?provider=google' + + await oauthAdapter.startOAuth(authUrl) + + expect(mockGetOAuthRedirectUrl).toHaveBeenCalledTimes(1) + expect(mockWarmUp).toHaveBeenCalledTimes(1) + expect(mockOpenAuthSession).toHaveBeenCalledWith(authUrl, 'everfreenote-dev://auth/callback') + expect(mockCoolDown).toHaveBeenCalledTimes(1) + }) + + it('handles result.type = "cancel" gracefully without throwing', async () => { + mockOpenAuthSession.mockResolvedValueOnce({ type: 'cancel' }) + const authUrl = 'https://example.supabase.co/auth/v1/authorize?provider=github' + + await expect(oauthAdapter.startOAuth(authUrl)).resolves.toBeUndefined() + + expect(mockWarmUp).toHaveBeenCalledTimes(1) + expect(mockOpenAuthSession).toHaveBeenCalledWith(authUrl, 'everfreenote-dev://auth/callback') + expect(mockCoolDown).toHaveBeenCalledTimes(1) + }) + + it('handles result.type = "dismiss" or other non-success types gracefully', async () => { + mockOpenAuthSession.mockResolvedValueOnce({ type: 'dismiss' }) + const authUrl = 'https://example.supabase.co/auth/v1/authorize?provider=apple' + + await expect(oauthAdapter.startOAuth(authUrl)).resolves.toBeUndefined() + + expect(mockCoolDown).toHaveBeenCalledTimes(1) + }) + + it('re-throws error when openAuthSessionAsync fails', async () => { + const error = new Error('Browser failed to launch') + mockOpenAuthSession.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + const authUrl = 'https://example.supabase.co/auth/v1/authorize?provider=google' + + await expect(oauthAdapter.startOAuth(authUrl)).rejects.toThrow('Browser failed to launch') + + expect(consoleErrorSpy).toHaveBeenCalledWith('[OAuth] Error starting OAuth flow:', error) + + consoleErrorSpy.mockRestore() + }) + + it('re-throws error when warmUpAsync fails', async () => { + const error = new Error('Warmup failed') + mockWarmUp.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(oauthAdapter.startOAuth('https://auth.com')).rejects.toThrow('Warmup failed') + + expect(consoleErrorSpy).toHaveBeenCalledWith('[OAuth] Error starting OAuth flow:', error) + + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/ui/mobile/tests/unit/offlineStorage.test.ts b/ui/mobile/tests/unit/offlineStorage.test.ts new file mode 100644 index 00000000000..38771d634a1 --- /dev/null +++ b/ui/mobile/tests/unit/offlineStorage.test.ts @@ -0,0 +1,310 @@ +import type { CachedNote, MutationQueueItem } from '@core/types/offline' +import { mobileOfflineStorageAdapter } from '../../adapters/offlineStorage' +import { databaseService } from '../../services/database' + +const mockDb = { + runAsync: jest.fn().mockResolvedValue(undefined), + execAsync: jest.fn().mockResolvedValue(undefined), +} + +jest.mock('../../services/database', () => ({ + databaseService: { + init: jest.fn().mockResolvedValue(mockDb), + saveNotes: jest.fn().mockResolvedValue(undefined), + getQueue: jest.fn().mockResolvedValue([]), + upsertQueueItem: jest.fn().mockResolvedValue(undefined), + removeQueueItems: jest.fn().mockResolvedValue(undefined), + markQueueItemStatus: jest.fn().mockResolvedValue(undefined), + }, +})) + +describe('mobileOfflineStorageAdapter', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(databaseService.init as jest.Mock).mockResolvedValue(mockDb) + mockDb.runAsync.mockResolvedValue(undefined) + mockDb.execAsync.mockResolvedValue(undefined) + }) + + describe('loadNotes', () => { + it('returns an empty array', async () => { + const result = await mobileOfflineStorageAdapter.loadNotes() + expect(result).toEqual([]) + }) + + it('returns an empty array when params are provided', async () => { + const result = await mobileOfflineStorageAdapter.loadNotes({ limit: 10, offset: 0 }) + expect(result).toEqual([]) + }) + }) + + describe('saveNote', () => { + it('delegates to databaseService.saveNotes with single note array', async () => { + const note: CachedNote = { + id: 'note-1', + title: 'Test Note', + status: 'synced', + updatedAt: '2026-07-24T12:00:00.000Z', + } + + await mobileOfflineStorageAdapter.saveNote(note) + + expect(databaseService.saveNotes).toHaveBeenCalledTimes(1) + expect(databaseService.saveNotes).toHaveBeenCalledWith([note]) + }) + }) + + describe('saveNotes', () => { + it('delegates to databaseService.saveNotes with notes array', async () => { + const notes: CachedNote[] = [ + { id: 'note-1', title: 'Note 1', status: 'synced', updatedAt: '2026-07-24T12:00:00.000Z' }, + { id: 'note-2', title: 'Note 2', status: 'pending', updatedAt: '2026-07-24T12:01:00.000Z' }, + ] + + await mobileOfflineStorageAdapter.saveNotes(notes) + + expect(databaseService.saveNotes).toHaveBeenCalledTimes(1) + expect(databaseService.saveNotes).toHaveBeenCalledWith(notes) + }) + + it('handles empty notes array', async () => { + await mobileOfflineStorageAdapter.saveNotes([]) + + expect(databaseService.saveNotes).toHaveBeenCalledTimes(1) + expect(databaseService.saveNotes).toHaveBeenCalledWith([]) + }) + }) + + describe('deleteNote', () => { + it('updates is_deleted = 1 for given noteId in DB', async () => { + await mobileOfflineStorageAdapter.deleteNote('note-123') + + expect(databaseService.init).toHaveBeenCalledTimes(1) + expect(mockDb.runAsync).toHaveBeenCalledWith( + 'UPDATE notes SET is_deleted = 1 WHERE id = ?', + ['note-123'] + ) + }) + }) + + describe('getQueue', () => { + it('delegates to databaseService.getQueue', async () => { + const queueItems: MutationQueueItem[] = [ + { + id: 'q-1', + noteId: 'note-1', + operation: 'create', + payload: { title: 'Test' }, + clientUpdatedAt: '2026-07-24T12:00:00.000Z', + status: 'pending', + }, + ] + ;(databaseService.getQueue as jest.Mock).mockResolvedValueOnce(queueItems) + + const result = await mobileOfflineStorageAdapter.getQueue() + + expect(databaseService.getQueue).toHaveBeenCalledTimes(1) + expect(result).toEqual(queueItems) + }) + }) + + describe('upsertQueueItem', () => { + it('delegates to databaseService.upsertQueueItem', async () => { + const item: MutationQueueItem = { + id: 'q-1', + noteId: 'note-1', + operation: 'update', + payload: { title: 'Updated' }, + clientUpdatedAt: '2026-07-24T12:00:00.000Z', + status: 'pending', + } + + await mobileOfflineStorageAdapter.upsertQueueItem(item) + + expect(databaseService.upsertQueueItem).toHaveBeenCalledTimes(1) + expect(databaseService.upsertQueueItem).toHaveBeenCalledWith(item) + }) + }) + + describe('upsertQueue', () => { + it('calls upsertQueueItem for each item in array', async () => { + const items: MutationQueueItem[] = [ + { + id: 'q-1', + noteId: 'note-1', + operation: 'create', + payload: { title: 'Note 1' }, + clientUpdatedAt: '2026-07-24T12:00:00.000Z', + status: 'pending', + }, + { + id: 'q-2', + noteId: 'note-2', + operation: 'delete', + payload: {}, + clientUpdatedAt: '2026-07-24T12:01:00.000Z', + status: 'pending', + }, + ] + + await mobileOfflineStorageAdapter.upsertQueue(items) + + expect(databaseService.upsertQueueItem).toHaveBeenCalledTimes(2) + expect(databaseService.upsertQueueItem).toHaveBeenNthCalledWith(1, items[0]) + expect(databaseService.upsertQueueItem).toHaveBeenNthCalledWith(2, items[1]) + }) + + it('does not call upsertQueueItem for empty array', async () => { + await mobileOfflineStorageAdapter.upsertQueue([]) + + expect(databaseService.upsertQueueItem).not.toHaveBeenCalled() + }) + }) + + describe('getPendingBatch', () => { + it('filters queue items for pending status and respects batch size limit', async () => { + const queueItems: MutationQueueItem[] = [ + { + id: 'q-1', + noteId: 'note-1', + operation: 'create', + payload: {}, + clientUpdatedAt: '100', + status: 'pending', + }, + { + id: 'q-2', + noteId: 'note-2', + operation: 'update', + payload: {}, + clientUpdatedAt: '101', + status: 'failed', + }, + { + id: 'q-3', + noteId: 'note-3', + operation: 'update', + payload: {}, + clientUpdatedAt: '102', + status: 'pending', + }, + { + id: 'q-4', + noteId: 'note-4', + operation: 'delete', + payload: {}, + clientUpdatedAt: '103', + status: 'pending', + }, + ] + ;(databaseService.getQueue as jest.Mock).mockResolvedValueOnce(queueItems) + + const result = await mobileOfflineStorageAdapter.getPendingBatch(2) + + expect(databaseService.getQueue).toHaveBeenCalledTimes(1) + expect(result).toEqual([queueItems[0], queueItems[2]]) + }) + + it('returns empty array when queue is empty', async () => { + ;(databaseService.getQueue as jest.Mock).mockResolvedValueOnce([]) + + const result = await mobileOfflineStorageAdapter.getPendingBatch(5) + + expect(result).toEqual([]) + }) + }) + + describe('removeQueueItems', () => { + it('delegates to databaseService.removeQueueItems', async () => { + const ids = ['q-1', 'q-2'] + + await mobileOfflineStorageAdapter.removeQueueItems(ids) + + expect(databaseService.removeQueueItems).toHaveBeenCalledTimes(1) + expect(databaseService.removeQueueItems).toHaveBeenCalledWith(ids) + }) + }) + + describe('markSynced', () => { + it('updates is_synced and updated_at in DB for noteId', async () => { + await mobileOfflineStorageAdapter.markSynced('note-1', '2026-07-24T12:00:00.000Z') + + expect(databaseService.init).toHaveBeenCalledTimes(1) + expect(mockDb.runAsync).toHaveBeenCalledWith( + 'UPDATE notes SET is_synced = 1, updated_at = ? WHERE id = ?', + ['2026-07-24T12:00:00.000Z', 'note-1'] + ) + }) + }) + + describe('markQueueItemStatus', () => { + it('delegates status and error to databaseService.markQueueItemStatus', async () => { + await mobileOfflineStorageAdapter.markQueueItemStatus('q-1', 'failed', 'Connection lost') + + expect(databaseService.markQueueItemStatus).toHaveBeenCalledTimes(1) + expect(databaseService.markQueueItemStatus).toHaveBeenCalledWith('q-1', 'failed', 'Connection lost') + }) + + it('passes undefined for lastError if omitted', async () => { + await mobileOfflineStorageAdapter.markQueueItemStatus('q-1', 'synced') + + expect(databaseService.markQueueItemStatus).toHaveBeenCalledTimes(1) + expect(databaseService.markQueueItemStatus).toHaveBeenCalledWith('q-1', 'synced', undefined) + }) + }) + + describe('enforceLimit', () => { + it('resolves without error', async () => { + await expect(mobileOfflineStorageAdapter.enforceLimit()).resolves.toBeUndefined() + }) + }) + + describe('clearAll', () => { + it('executes delete statements on notes and mutation_queue', async () => { + await mobileOfflineStorageAdapter.clearAll() + + expect(databaseService.init).toHaveBeenCalledTimes(1) + expect(mockDb.execAsync).toHaveBeenCalledWith( + 'DELETE FROM notes; DELETE FROM mutation_queue;' + ) + }) + }) + + describe('popQueueBatch', () => { + it('fetches pending batch, removes items by id, and returns batch', async () => { + const pendingItems: MutationQueueItem[] = [ + { + id: 'q-1', + noteId: 'note-1', + operation: 'create', + payload: {}, + clientUpdatedAt: '100', + status: 'pending', + }, + { + id: 'q-2', + noteId: 'note-2', + operation: 'update', + payload: {}, + clientUpdatedAt: '101', + status: 'pending', + }, + ] + ;(databaseService.getQueue as jest.Mock).mockResolvedValueOnce(pendingItems) + + const result = await mobileOfflineStorageAdapter.popQueueBatch(2) + + expect(result).toEqual(pendingItems) + expect(databaseService.removeQueueItems).toHaveBeenCalledWith(['q-1', 'q-2']) + }) + + it('returns empty array and removes empty array when no pending items exist', async () => { + ;(databaseService.getQueue as jest.Mock).mockResolvedValueOnce([]) + + const result = await mobileOfflineStorageAdapter.popQueueBatch(2) + + expect(result).toEqual([]) + expect(databaseService.removeQueueItems).toHaveBeenCalledWith([]) + }) + }) +}) diff --git a/ui/mobile/tests/unit/searchHistory.test.ts b/ui/mobile/tests/unit/searchHistory.test.ts new file mode 100644 index 00000000000..ef3613c8e64 --- /dev/null +++ b/ui/mobile/tests/unit/searchHistory.test.ts @@ -0,0 +1,158 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { + getSearchHistory, + addSearchHistoryItem, + clearSearchHistory, +} from '@ui/mobile/services/searchHistory' + +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +) + +describe('searchHistory service', () => { + const userId = 'user-123' + const expectedStorageKey = `everfreenote.searchHistory.${userId}` + const mockStorage: Record = {} + + beforeEach(() => { + jest.clearAllMocks() + for (const key of Object.keys(mockStorage)) { + delete mockStorage[key] + } + + ;(AsyncStorage.getItem as jest.Mock).mockImplementation(async (key: string) => mockStorage[key] ?? null) + ;(AsyncStorage.setItem as jest.Mock).mockImplementation(async (key: string, value: string) => { + mockStorage[key] = value + }) + ;(AsyncStorage.removeItem as jest.Mock).mockImplementation(async (key: string) => { + delete mockStorage[key] + }) + }) + + describe('getSearchHistory', () => { + it('returns an empty array when storage is empty', async () => { + const history = await getSearchHistory(userId) + expect(AsyncStorage.getItem).toHaveBeenCalledWith(expectedStorageKey) + expect(history).toEqual([]) + }) + + it('returns stored search history array', async () => { + const mockData = ['react native', 'typescript'] + mockStorage[expectedStorageKey] = JSON.stringify(mockData) + + const history = await getSearchHistory(userId) + expect(history).toEqual(['react native', 'typescript']) + }) + + it('filters out empty or whitespace-only items', async () => { + const mockData = ['react', ' ', '', 'jest'] + mockStorage[expectedStorageKey] = JSON.stringify(mockData) + + const history = await getSearchHistory(userId) + expect(history).toEqual(['react', 'jest']) + }) + + it('limits returned items to max 10 items', async () => { + const mockData = Array.from({ length: 15 }, (_, i) => `item-${i + 1}`) + mockStorage[expectedStorageKey] = JSON.stringify(mockData) + + const history = await getSearchHistory(userId) + expect(history).toHaveLength(10) + expect(history).toEqual([ + 'item-1', + 'item-2', + 'item-3', + 'item-4', + 'item-5', + 'item-6', + 'item-7', + 'item-8', + 'item-9', + 'item-10', + ]) + }) + + it('handles non-string elements gracefully', async () => { + const mockData = [123, 'valid query', true] + mockStorage[expectedStorageKey] = JSON.stringify(mockData) + + const history = await getSearchHistory(userId) + expect(history).toEqual(['123', 'valid query', 'true']) + }) + + it('returns empty array if parsed JSON is not an array', async () => { + mockStorage[expectedStorageKey] = JSON.stringify({ key: 'value' }) + + const history = await getSearchHistory(userId) + expect(history).toEqual([]) + }) + + it('returns empty array on invalid JSON', async () => { + mockStorage[expectedStorageKey] = 'invalid-json{' + + const history = await getSearchHistory(userId) + expect(history).toEqual([]) + }) + }) + + describe('addSearchHistoryItem', () => { + it('does not add items with length less than 2', async () => { + mockStorage[expectedStorageKey] = JSON.stringify(['existing']) + + const resultEmpty = await addSearchHistoryItem(userId, '') + expect(resultEmpty).toEqual(['existing']) + + const resultWhitespace = await addSearchHistoryItem(userId, ' ') + expect(resultWhitespace).toEqual(['existing']) + + const resultSingleChar = await addSearchHistoryItem(userId, ' a ') + expect(resultSingleChar).toEqual(['existing']) + }) + + it('adds a valid item to the front of history', async () => { + mockStorage[expectedStorageKey] = JSON.stringify(['old query']) + + const updated = await addSearchHistoryItem(userId, 'new query') + expect(updated).toEqual(['new query', 'old query']) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + expectedStorageKey, + JSON.stringify(['new query', 'old query']) + ) + }) + + it('trims whitespace when adding a query', async () => { + const updated = await addSearchHistoryItem(userId, ' search term ') + expect(updated).toEqual(['search term']) + }) + + it('normalizes duplicates (case-insensitive and trimmed)', async () => { + mockStorage[expectedStorageKey] = JSON.stringify(['React Native', 'TypeScript']) + + const updated = await addSearchHistoryItem(userId, ' react native ') + expect(updated).toEqual(['react native', 'TypeScript']) + }) + + it('limits history to max 10 items when adding', async () => { + const existing = Array.from({ length: 10 }, (_, i) => `item-${i + 1}`) + mockStorage[expectedStorageKey] = JSON.stringify(existing) + + const updated = await addSearchHistoryItem(userId, 'item-new') + expect(updated).toHaveLength(10) + expect(updated[0]).toBe('item-new') + expect(updated[9]).toBe('item-9') + expect(updated).not.toContain('item-10') + }) + }) + + describe('clearSearchHistory', () => { + it('removes history from storage', async () => { + mockStorage[expectedStorageKey] = JSON.stringify(['query1', 'query2']) + + await clearSearchHistory(userId) + expect(AsyncStorage.removeItem).toHaveBeenCalledWith(expectedStorageKey) + + const history = await getSearchHistory(userId) + expect(history).toEqual([]) + }) + }) +}) diff --git a/ui/mobile/tests/unit/storageAdapter.test.ts b/ui/mobile/tests/unit/storageAdapter.test.ts new file mode 100644 index 00000000000..d23b53f3918 --- /dev/null +++ b/ui/mobile/tests/unit/storageAdapter.test.ts @@ -0,0 +1,187 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { + asyncStorageAdapter, + secureStorageAdapter, + storageAdapter, +} from '../../adapters/storage' + +jest.mock('expo-secure-store', () => ({ + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', +})) + +describe('storage adapters', () => { + const mockAsyncStorage = AsyncStorage as jest.Mocked + const mockSecureStore = SecureStore as jest.Mocked + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('asyncStorageAdapter', () => { + describe('getItem', () => { + it('returns value from AsyncStorage.getItem', async () => { + mockAsyncStorage.getItem.mockResolvedValueOnce('stored-value') + + const result = await asyncStorageAdapter.getItem('test-key') + + expect(mockAsyncStorage.getItem).toHaveBeenCalledWith('test-key') + expect(result).toBe('stored-value') + }) + + it('returns null when item is not found', async () => { + mockAsyncStorage.getItem.mockResolvedValueOnce(null) + + const result = await asyncStorageAdapter.getItem('missing-key') + + expect(result).toBeNull() + }) + + it('logs error and returns null when AsyncStorage.getItem throws', async () => { + const error = new Error('AsyncStorage failure') + mockAsyncStorage.getItem.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + const result = await asyncStorageAdapter.getItem('error-key') + + expect(result).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith('[AsyncStorage] getItem error:', error) + + consoleErrorSpy.mockRestore() + }) + }) + + describe('setItem', () => { + it('calls AsyncStorage.setItem with key and value', async () => { + mockAsyncStorage.setItem.mockResolvedValueOnce(undefined) + + await asyncStorageAdapter.setItem('key1', 'val1') + + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith('key1', 'val1') + }) + + it('logs and re-throws error when AsyncStorage.setItem fails', async () => { + const error = new Error('Disk full') + mockAsyncStorage.setItem.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(asyncStorageAdapter.setItem('key1', 'val1')).rejects.toThrow('Disk full') + expect(consoleErrorSpy).toHaveBeenCalledWith('[AsyncStorage] setItem error:', error) + + consoleErrorSpy.mockRestore() + }) + }) + + describe('removeItem', () => { + it('calls AsyncStorage.removeItem with key', async () => { + mockAsyncStorage.removeItem.mockResolvedValueOnce(undefined) + + await asyncStorageAdapter.removeItem('key1') + + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith('key1') + }) + + it('logs and re-throws error when AsyncStorage.removeItem fails', async () => { + const error = new Error('Remove failed') + mockAsyncStorage.removeItem.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(asyncStorageAdapter.removeItem('key1')).rejects.toThrow('Remove failed') + expect(consoleErrorSpy).toHaveBeenCalledWith('[AsyncStorage] removeItem error:', error) + + consoleErrorSpy.mockRestore() + }) + }) + }) + + describe('secureStorageAdapter', () => { + describe('getItem', () => { + it('returns value from SecureStore.getItemAsync', async () => { + mockSecureStore.getItemAsync.mockResolvedValueOnce('secret-token') + + const result = await secureStorageAdapter.getItem('auth-token') + + expect(mockSecureStore.getItemAsync).toHaveBeenCalledWith('auth-token') + expect(result).toBe('secret-token') + }) + + it('returns null when item is not found', async () => { + mockSecureStore.getItemAsync.mockResolvedValueOnce(null) + + const result = await secureStorageAdapter.getItem('missing-token') + + expect(result).toBeNull() + }) + + it('logs error and returns null when SecureStore.getItemAsync throws', async () => { + const error = new Error('Keychain inaccessible') + mockSecureStore.getItemAsync.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + const result = await secureStorageAdapter.getItem('error-token') + + expect(result).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith('[SecureStore] getItem error:', error) + + consoleErrorSpy.mockRestore() + }) + }) + + describe('setItem', () => { + it('calls SecureStore.setItemAsync with WHEN_UNLOCKED_THIS_DEVICE_ONLY options', async () => { + mockSecureStore.setItemAsync.mockResolvedValueOnce(undefined) + + await secureStorageAdapter.setItem('auth-token', 'my-secret') + + expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith('auth-token', 'my-secret', { + keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + }) + }) + + it('logs and re-throws error when SecureStore.setItemAsync fails', async () => { + const error = new Error('SecureStore write error') + mockSecureStore.setItemAsync.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(secureStorageAdapter.setItem('auth-token', 'my-secret')).rejects.toThrow( + 'SecureStore write error' + ) + expect(consoleErrorSpy).toHaveBeenCalledWith('[SecureStore] setItem error:', error) + + consoleErrorSpy.mockRestore() + }) + }) + + describe('removeItem', () => { + it('calls SecureStore.deleteItemAsync with key', async () => { + mockSecureStore.deleteItemAsync.mockResolvedValueOnce(undefined) + + await secureStorageAdapter.removeItem('auth-token') + + expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith('auth-token') + }) + + it('logs and re-throws error when SecureStore.deleteItemAsync fails', async () => { + const error = new Error('SecureStore delete error') + mockSecureStore.deleteItemAsync.mockRejectedValueOnce(error) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(secureStorageAdapter.removeItem('auth-token')).rejects.toThrow( + 'SecureStore delete error' + ) + expect(consoleErrorSpy).toHaveBeenCalledWith('[SecureStore] removeItem error:', error) + + consoleErrorSpy.mockRestore() + }) + }) + }) + + describe('storageAdapter export', () => { + it('exports storageAdapter as asyncStorageAdapter', () => { + expect(storageAdapter).toBe(asyncStorageAdapter) + }) + }) +})