diff --git a/mobile/src/__mocks__/@react-native-async-storage/async-storage.ts b/mobile/src/__mocks__/@react-native-async-storage/async-storage.ts new file mode 100644 index 00000000..8cc01eaa --- /dev/null +++ b/mobile/src/__mocks__/@react-native-async-storage/async-storage.ts @@ -0,0 +1,42 @@ +// Mock AsyncStorage for testing +const storage: { [key: string]: string } = {}; + +export default { + setItem: jest.fn((key: string, value: string) => { + storage[key] = value; + return Promise.resolve(); + }), + getItem: jest.fn((key: string) => { + return Promise.resolve(storage[key] || null); + }), + removeItem: jest.fn((key: string) => { + delete storage[key]; + return Promise.resolve(); + }), + clear: jest.fn(() => { + Object.keys(storage).forEach(key => delete storage[key]); + return Promise.resolve(); + }), + getAllKeys: jest.fn(() => { + return Promise.resolve(Object.keys(storage)); + }), + multiGet: jest.fn((keys: string[]) => { + return Promise.resolve( + keys.map(key => [key, storage[key] || null]) + ); + }), + multiSet: jest.fn((keyValuePairs: [string, string][]) => { + keyValuePairs.forEach(([key, value]) => { + storage[key] = value; + }); + return Promise.resolve(); + }), + multiRemove: jest.fn((keys: string[]) => { + keys.forEach(key => delete storage[key]); + return Promise.resolve(); + }), + _clear: () => { + Object.keys(storage).forEach(key => delete storage[key]); + }, + _getStorage: () => storage, +}; diff --git a/mobile/src/__mocks__/contexts/AuthContext.tsx b/mobile/src/__mocks__/contexts/AuthContext.tsx new file mode 100644 index 00000000..ec40fb66 --- /dev/null +++ b/mobile/src/__mocks__/contexts/AuthContext.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, ReactNode } from 'react'; +import { User } from '@supabase/supabase-js'; + +// Mock Auth Context for testing +interface AuthContextType { + user: User | null; + session: any | null; + loading: boolean; + signIn: (email: string, password: string) => Promise<{ error: any }>; + signUp: (email: string, password: string, fullName: string) => Promise<{ error: any }>; + signOut: () => Promise; + changePassword: (newPassword: string, currentPassword: string) => Promise<{ error: any }>; + resetPassword: (email: string) => Promise<{ error: any }>; +} + +const mockUser: User = { + id: 'test-user-id', + email: 'test@pezkuwichain.io', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: new Date().toISOString(), +}; + +const mockAuthContext: AuthContextType = { + user: mockUser, + session: null, + loading: false, + signIn: jest.fn().mockResolvedValue({ error: null }), + signUp: jest.fn().mockResolvedValue({ error: null }), + signOut: jest.fn().mockResolvedValue(undefined), + changePassword: jest.fn().mockResolvedValue({ error: null }), + resetPassword: jest.fn().mockResolvedValue({ error: null }), +}; + +const AuthContext = createContext(mockAuthContext); + +export const MockAuthProvider: React.FC<{ + children: ReactNode; + value?: Partial +}> = ({ children, value = {} }) => { + const contextValue = { ...mockAuthContext, ...value }; + return {children}; +}; + +export const useAuth = () => useContext(AuthContext); + +export default AuthContext; diff --git a/mobile/src/__mocks__/contexts/BiometricAuthContext.tsx b/mobile/src/__mocks__/contexts/BiometricAuthContext.tsx new file mode 100644 index 00000000..2ee71dfa --- /dev/null +++ b/mobile/src/__mocks__/contexts/BiometricAuthContext.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useContext, ReactNode } from 'react'; + +// Mock Biometric Auth Context for testing +interface BiometricAuthContextType { + isBiometricSupported: boolean; + isBiometricEnrolled: boolean; + isBiometricAvailable: boolean; + biometricType: 'fingerprint' | 'facial' | 'iris' | 'none'; + isBiometricEnabled: boolean; + authenticate: () => Promise; + enableBiometric: () => Promise; + disableBiometric: () => Promise; +} + +const mockBiometricContext: BiometricAuthContextType = { + isBiometricSupported: true, + isBiometricEnrolled: true, + isBiometricAvailable: true, + biometricType: 'fingerprint', + isBiometricEnabled: false, + authenticate: jest.fn().mockResolvedValue(true), + enableBiometric: jest.fn().mockResolvedValue(true), + disableBiometric: jest.fn().mockResolvedValue(undefined), +}; + +const BiometricAuthContext = createContext(mockBiometricContext); + +export const MockBiometricAuthProvider: React.FC<{ + children: ReactNode; + value?: Partial +}> = ({ children, value = {} }) => { + const contextValue = { ...mockBiometricContext, ...value }; + return {children}; +}; + +export const useBiometricAuth = () => useContext(BiometricAuthContext); + +export default BiometricAuthContext; diff --git a/mobile/src/__mocks__/contexts/ThemeContext.tsx b/mobile/src/__mocks__/contexts/ThemeContext.tsx new file mode 100644 index 00000000..0eb2f9bc --- /dev/null +++ b/mobile/src/__mocks__/contexts/ThemeContext.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, ReactNode } from 'react'; + +// Mock colors instead of importing from shared +const LightColors = { + background: '#F5F5F5', + surface: '#FFFFFF', + text: '#000000', + textSecondary: '#666666', + border: '#E0E0E0', +}; + +// Mock Theme Context for testing +interface ThemeContextType { + isDarkMode: boolean; + toggleDarkMode: () => Promise; + colors: typeof LightColors; + fontSize: 'small' | 'medium' | 'large'; + setFontSize: (size: 'small' | 'medium' | 'large') => Promise; + fontScale: number; +} + +const mockThemeContext: ThemeContextType = { + isDarkMode: false, + toggleDarkMode: jest.fn().mockResolvedValue(undefined), + colors: LightColors, + fontSize: 'medium', + setFontSize: jest.fn().mockResolvedValue(undefined), + fontScale: 1, +}; + +const ThemeContext = createContext(mockThemeContext); + +export const MockThemeProvider: React.FC<{ children: ReactNode; value?: Partial }> = ({ + children, + value = {} +}) => { + const contextValue = { ...mockThemeContext, ...value }; + return {children}; +}; + +export const useTheme = () => useContext(ThemeContext); + +export default ThemeContext; diff --git a/mobile/src/__tests__/screens/SettingsScreen.DarkMode.test.tsx b/mobile/src/__tests__/screens/SettingsScreen.DarkMode.test.tsx new file mode 100644 index 00000000..d56bd578 --- /dev/null +++ b/mobile/src/__tests__/screens/SettingsScreen.DarkMode.test.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Mock contexts before importing SettingsScreen +jest.mock('../../contexts/ThemeContext', () => require('../../__mocks__/contexts/ThemeContext')); +jest.mock('../../contexts/BiometricAuthContext', () => require('../../__mocks__/contexts/BiometricAuthContext')); +jest.mock('../../contexts/AuthContext', () => require('../../__mocks__/contexts/AuthContext')); + +// Mock Alert +jest.mock('react-native/Libraries/Alert/Alert', () => ({ + alert: jest.fn(), +})); + +import SettingsScreen from '../../screens/SettingsScreen'; +import { MockThemeProvider } from '../../__mocks__/contexts/ThemeContext'; +import { MockBiometricAuthProvider } from '../../__mocks__/contexts/BiometricAuthContext'; +import { MockAuthProvider } from '../../__mocks__/contexts/AuthContext'; + +// Helper to render SettingsScreen with all required providers +const renderSettingsScreen = (themeValue = {}, biometricValue = {}, authValue = {}) => { + return render( + + + + + + + + ); +}; + +describe('SettingsScreen - Dark Mode Feature', () => { + beforeEach(async () => { + jest.clearAllMocks(); + // Clear AsyncStorage before each test + await AsyncStorage.clear(); + }); + + describe('Rendering', () => { + it('should render Dark Mode section with toggle', () => { + const { getByText } = renderSettingsScreen(); + + expect(getByText('APPEARANCE')).toBeTruthy(); + expect(getByText('darkMode')).toBeTruthy(); + }); + + it('should show current dark mode state', () => { + const { getByText } = renderSettingsScreen({ isDarkMode: false }); + + // Should show subtitle when dark mode is off + expect(getByText(/settingsScreen.subtitles.lightThemeEnabled/i)).toBeTruthy(); + }); + + it('should show "Enabled" when dark mode is on', () => { + const { getByText } = renderSettingsScreen({ isDarkMode: true }); + + // Should show subtitle when dark mode is on + expect(getByText(/settingsScreen.subtitles.darkThemeEnabled/i)).toBeTruthy(); + }); + }); + + describe('Toggle Functionality', () => { + it('should toggle dark mode when switch is pressed', async () => { + const mockToggleDarkMode = jest.fn().mockResolvedValue(undefined); + const { getByTestId } = renderSettingsScreen({ + isDarkMode: false, + toggleDarkMode: mockToggleDarkMode, + }); + + // Find the toggle switch + const darkModeSwitch = getByTestId('dark-mode-switch'); + + // Toggle the switch + fireEvent(darkModeSwitch, 'valueChange', true); + + // Verify toggleDarkMode was called + await waitFor(() => { + expect(mockToggleDarkMode).toHaveBeenCalledTimes(1); + }); + }); + + it('should toggle from enabled to disabled', async () => { + const mockToggleDarkMode = jest.fn().mockResolvedValue(undefined); + const { getByTestId } = renderSettingsScreen({ + isDarkMode: true, + toggleDarkMode: mockToggleDarkMode, + }); + + const darkModeSwitch = getByTestId('dark-mode-switch'); + + // Toggle off + fireEvent(darkModeSwitch, 'valueChange', false); + + await waitFor(() => { + expect(mockToggleDarkMode).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Persistence', () => { + it('should save dark mode preference to AsyncStorage', async () => { + const mockToggleDarkMode = jest.fn(async () => { + await AsyncStorage.setItem('@pezkuwi/theme', 'dark'); + }); + + const { getByTestId } = renderSettingsScreen({ + isDarkMode: false, + toggleDarkMode: mockToggleDarkMode, + }); + + const darkModeSwitch = getByTestId('dark-mode-switch'); + fireEvent(darkModeSwitch, 'valueChange', true); + + await waitFor(() => { + expect(AsyncStorage.setItem).toHaveBeenCalledWith('@pezkuwi/theme', 'dark'); + }); + }); + + it('should load dark mode preference on mount', async () => { + // Pre-set dark mode in AsyncStorage + await AsyncStorage.setItem('@pezkuwi/theme', 'dark'); + + const { getByText } = renderSettingsScreen({ isDarkMode: true }); + + // Verify dark mode is enabled - check for dark theme subtitle + expect(getByText(/settingsScreen.subtitles.darkThemeEnabled/i)).toBeTruthy(); + }); + }); + + describe('Theme Application', () => { + it('should apply dark theme colors when enabled', () => { + const darkColors = { + background: '#121212', + surface: '#1E1E1E', + text: '#FFFFFF', + textSecondary: '#B0B0B0', + border: '#333333', + }; + + const { getByTestId } = renderSettingsScreen({ + isDarkMode: true, + colors: darkColors, + }); + + const container = getByTestId('settings-screen'); + + // Verify dark background is applied + expect(container.props.style).toEqual( + expect.objectContaining({ + backgroundColor: darkColors.background, + }) + ); + }); + + it('should apply light theme colors when disabled', () => { + const lightColors = { + background: '#F5F5F5', + surface: '#FFFFFF', + text: '#000000', + textSecondary: '#666666', + border: '#E0E0E0', + }; + + const { getByTestId } = renderSettingsScreen({ + isDarkMode: false, + colors: lightColors, + }); + + const container = getByTestId('settings-screen'); + + // Verify light background is applied + expect(container.props.style).toEqual( + expect.objectContaining({ + backgroundColor: lightColors.background, + }) + ); + }); + }); + + describe('Edge Cases', () => { + it('should handle rapid toggle clicks', async () => { + const mockToggleDarkMode = jest.fn().mockResolvedValue(undefined); + const { getByTestId } = renderSettingsScreen({ + isDarkMode: false, + toggleDarkMode: mockToggleDarkMode, + }); + + const darkModeSwitch = getByTestId('dark-mode-switch'); + + // Rapid clicks + fireEvent(darkModeSwitch, 'valueChange', true); + fireEvent(darkModeSwitch, 'valueChange', false); + fireEvent(darkModeSwitch, 'valueChange', true); + + await waitFor(() => { + // Should handle all toggle attempts + expect(mockToggleDarkMode).toHaveBeenCalled(); + }); + }); + + it('should call toggleDarkMode multiple times without issues', async () => { + const mockToggleDarkMode = jest.fn().mockResolvedValue(undefined); + const { getByTestId } = renderSettingsScreen({ + isDarkMode: false, + toggleDarkMode: mockToggleDarkMode, + }); + + const darkModeSwitch = getByTestId('dark-mode-switch'); + + // Toggle multiple times + fireEvent(darkModeSwitch, 'valueChange', true); + fireEvent(darkModeSwitch, 'valueChange', false); + fireEvent(darkModeSwitch, 'valueChange', true); + + await waitFor(() => { + // Should handle all calls + expect(mockToggleDarkMode).toHaveBeenCalledTimes(3); + }); + }); + }); +}); diff --git a/mobile/src/screens/SettingsScreen.tsx b/mobile/src/screens/SettingsScreen.tsx index ba739989..65a3da56 100644 --- a/mobile/src/screens/SettingsScreen.tsx +++ b/mobile/src/screens/SettingsScreen.tsx @@ -111,12 +111,14 @@ const SettingsScreen: React.FC = () => { subtitle, value, onToggle, + testID, }: { icon: string; title: string; subtitle?: string; value: boolean; onToggle: (value: boolean) => void; + testID?: string; }) => ( @@ -127,6 +129,7 @@ const SettingsScreen: React.FC = () => { {subtitle && {subtitle}} { ); return ( - + {/* Header */} @@ -161,6 +164,7 @@ const SettingsScreen: React.FC = () => { onToggle={async () => { await toggleDarkMode(); }} + testID="dark-mode-switch" />