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');
+ });
+ });
+});