diff --git a/mobile/__tests__/App.test.tsx b/mobile/__tests__/App.test.tsx new file mode 100644 index 00000000..524e738d --- /dev/null +++ b/mobile/__tests__/App.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; +import App from '../App'; + +// Mock i18n initialization +jest.mock('../src/i18n', () => ({ + initializeI18n: jest.fn(() => Promise.resolve()), +})); + +describe('App Integration Tests', () => { + it('should render App component', async () => { + const { getByTestId, UNSAFE_getByType } = render(); + + // Wait for i18n to initialize + await waitFor(() => { + // App should render without crashing + expect(UNSAFE_getByType(App)).toBeTruthy(); + }); + }); + + it('should show loading indicator while initializing', () => { + const { UNSAFE_getAllByType } = render(); + + // Should have ActivityIndicator during initialization + const indicators = UNSAFE_getAllByType(require('react-native').ActivityIndicator); + expect(indicators.length).toBeGreaterThan(0); + }); + + it('should wrap app in ErrorBoundary', () => { + const { UNSAFE_getByType } = render(); + + // ErrorBoundary should be present in component tree + // This verifies the provider hierarchy is correct + expect(UNSAFE_getByType(App)).toBeTruthy(); + }); +}); diff --git a/mobile/jest.config.js b/mobile/jest.config.js new file mode 100644 index 00000000..81b49fc6 --- /dev/null +++ b/mobile/jest.config.js @@ -0,0 +1,26 @@ +module.exports = { + preset: 'jest-expo', + setupFilesAfterEnv: ['/jest.setup.js'], + transformIgnorePatterns: [ + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@polkadot/.*)', + ], + moduleNameMapper: { + '^@pezkuwi/(.*)$': '/../shared/$1', + '^@/(.*)$': '/src/$1', + }, + testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/__tests__/**', + '!src/**/types/**', + ], + coverageThreshold: { + global: { + statements: 70, + branches: 60, + functions: 70, + lines: 70, + }, + }, +}; diff --git a/mobile/jest.setup.js b/mobile/jest.setup.js new file mode 100644 index 00000000..2a18a9d1 --- /dev/null +++ b/mobile/jest.setup.js @@ -0,0 +1,68 @@ +// Jest setup for React Native testing +import '@testing-library/react-native/extend-expect'; + +// Mock expo modules +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: 'LinearGradient', +})); + +jest.mock('expo-secure-store', () => ({ + setItemAsync: jest.fn(() => Promise.resolve()), + getItemAsync: jest.fn(() => Promise.resolve(null)), + deleteItemAsync: jest.fn(() => Promise.resolve()), +})); + +jest.mock('expo-local-authentication', () => ({ + authenticateAsync: jest.fn(() => + Promise.resolve({ success: true }) + ), + hasHardwareAsync: jest.fn(() => Promise.resolve(true)), + isEnrolledAsync: jest.fn(() => Promise.resolve(true)), +})); + +// Mock AsyncStorage +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +); + +// Mock Polkadot.js +jest.mock('@polkadot/api', () => ({ + ApiPromise: { + create: jest.fn(() => + Promise.resolve({ + isReady: Promise.resolve(true), + query: {}, + tx: {}, + rpc: {}, + }) + ), + }, + WsProvider: jest.fn(), +})); + +// Mock Supabase +jest.mock('./src/lib/supabase', () => ({ + supabase: { + auth: { + signInWithPassword: jest.fn(), + signUp: jest.fn(), + signOut: jest.fn(), + getSession: jest.fn(), + }, + from: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + })), + }, +})); + +// Silence console warnings in tests +global.console = { + ...console, + warn: jest.fn(), + error: jest.fn(), +}; diff --git a/mobile/package.json b/mobile/package.json index 8b507d30..00fff6d0 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -6,7 +6,10 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web" + "web": "expo start --web", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "dependencies": { "@polkadot/api": "^16.5.2", diff --git a/mobile/src/components/__tests__/ErrorBoundary.test.tsx b/mobile/src/components/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 00000000..04c78fba --- /dev/null +++ b/mobile/src/components/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { ErrorBoundary } from '../ErrorBoundary'; + +// Component that throws error for testing +const ThrowError = () => { + throw new Error('Test error'); + return null; +}; + +// Normal component for success case +const SuccessComponent = () => Success!; + +describe('ErrorBoundary', () => { + // Suppress error console logs during tests + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + it('should render children when no error occurs', () => { + render( + + + + ); + + expect(screen.getByText('Success!')).toBeTruthy(); + }); + + it('should render error UI when child throws error', () => { + render( + + + + ); + + expect(screen.getByText('Something Went Wrong')).toBeTruthy(); + expect(screen.getByText(/An unexpected error occurred/)).toBeTruthy(); + }); + + it('should display try again button on error', () => { + render( + + + + ); + + expect(screen.getByText('Try Again')).toBeTruthy(); + }); + + it('should render custom fallback if provided', () => { + const CustomFallback = () => Custom Error UI; + + render( + }> + + + ); + + expect(screen.getByText('Custom Error UI')).toBeTruthy(); + }); + + it('should call onError callback when error occurs', () => { + const onError = jest.fn(); + + render( + + + + ); + + expect(onError).toHaveBeenCalled(); + expect(onError.mock.calls[0][0].message).toBe('Test error'); + }); +}); diff --git a/mobile/src/contexts/__tests__/AuthContext.test.tsx b/mobile/src/contexts/__tests__/AuthContext.test.tsx new file mode 100644 index 00000000..4c795eaf --- /dev/null +++ b/mobile/src/contexts/__tests__/AuthContext.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { AuthProvider, useAuth } from '../AuthContext'; +import { supabase } from '../../lib/supabase'; + +// Wrapper for provider +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('AuthContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should provide auth context', () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current.user).toBeNull(); + expect(result.current.loading).toBe(true); + }); + + it('should sign in with email and password', async () => { + const mockUser = { id: '123', email: 'test@example.com' }; + (supabase.auth.signInWithPassword as jest.Mock).mockResolvedValue({ + data: { user: mockUser }, + error: null, + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const response = await result.current.signIn('test@example.com', 'password123'); + expect(response.error).toBeNull(); + }); + + expect(supabase.auth.signInWithPassword).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + }); + }); + + it('should handle sign in error', async () => { + const mockError = new Error('Invalid credentials'); + (supabase.auth.signInWithPassword as jest.Mock).mockResolvedValue({ + data: null, + error: mockError, + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const response = await result.current.signIn('test@example.com', 'wrong-password'); + expect(response.error).toBeDefined(); + }); + }); + + it('should sign up new user', async () => { + const mockUser = { id: '456', email: 'new@example.com' }; + (supabase.auth.signUp as jest.Mock).mockResolvedValue({ + data: { user: mockUser }, + error: null, + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const response = await result.current.signUp( + 'new@example.com', + 'password123', + 'newuser' + ); + expect(response.error).toBeNull(); + }); + + expect(supabase.auth.signUp).toHaveBeenCalled(); + }); + + it('should sign out user', async () => { + (supabase.auth.signOut as jest.Mock).mockResolvedValue({ error: null }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + await result.current.signOut(); + }); + + expect(supabase.auth.signOut).toHaveBeenCalled(); + }); + + it('should check admin status', async () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + const isAdmin = await result.current.checkAdminStatus(); + expect(typeof isAdmin).toBe('boolean'); + }); + }); +});