From 6f7b4552bce335240ee4bebb9853636a96947e0d Mon Sep 17 00:00:00 2001 From: Lucas Raposeiras Date: Fri, 19 Dec 2025 11:48:25 -0300 Subject: [PATCH] refactor: move test files to __tests__ directory --- .github/workflows/sonar.yml | 4 +- .../api/auth/use-forgot-password.test.tsx | 70 ++++++++ __tests__/api/auth/use-login.test.tsx | 105 ++++++++++++ __tests__/api/auth/use-sign-up.test.tsx | 128 +++++++++++++++ .../api/auth/use-update-password.test.tsx | 68 ++++++++ .../api/common/interceptors.test.ts | 2 +- .../api/common/utils.test.tsx | 2 +- __tests__/app/_layout.test.tsx | 139 ++++++++++++++++ __tests__/components/buttons.test.tsx | 37 +++++ {src => __tests__}/components/colors.test.tsx | 2 +- __tests__/components/cover.test.tsx | 21 +++ .../components/forgot-password-form.test.tsx | 2 +- __tests__/components/inputs.test.tsx | 101 ++++++++++++ .../components/login-form.test.tsx | 2 +- .../components/providers/auth.test.tsx | 2 +- __tests__/components/settings/item.test.tsx | 44 +++++ .../settings/items-container.test.tsx | 35 ++++ __tests__/components/sign-up-form.test.tsx | 151 ++++++++++++++++++ __tests__/components/typography.test.tsx | 26 +++ .../components/ui/button.test.tsx | 2 +- .../components/ui/checkbox.test.tsx | 2 +- .../components/ui/input.test.tsx | 2 +- .../modal-keyboard-aware-scroll-view.test.tsx | 2 +- .../components/ui/progress-bar.test.tsx | 4 +- .../components/ui/select.test.tsx | 2 +- .../lib/hooks/use-is-first-time.test.tsx | 62 +++++++ {src => __tests__}/lib/i18n/utils.test.ts | 10 +- __tests__/lib/storage.test.tsx | 92 +++++++++++ __tests__/lib/utils.test.ts | 68 ++++++++ src/app/(app)/settings.tsx | 2 +- src/translations/en.json | 2 +- 31 files changed, 1169 insertions(+), 22 deletions(-) create mode 100644 __tests__/api/auth/use-forgot-password.test.tsx create mode 100644 __tests__/api/auth/use-login.test.tsx create mode 100644 __tests__/api/auth/use-sign-up.test.tsx create mode 100644 __tests__/api/auth/use-update-password.test.tsx rename src/api/common/interceptors.spec.ts => __tests__/api/common/interceptors.test.ts (98%) rename src/api/common/utils.spec.tsx => __tests__/api/common/utils.test.tsx (97%) create mode 100644 __tests__/app/_layout.test.tsx create mode 100644 __tests__/components/buttons.test.tsx rename {src => __tests__}/components/colors.test.tsx (94%) create mode 100644 __tests__/components/cover.test.tsx rename {src => __tests__}/components/forgot-password-form.test.tsx (97%) create mode 100644 __tests__/components/inputs.test.tsx rename {src => __tests__}/components/login-form.test.tsx (95%) rename {src => __tests__}/components/providers/auth.test.tsx (99%) create mode 100644 __tests__/components/settings/item.test.tsx create mode 100644 __tests__/components/settings/items-container.test.tsx create mode 100644 __tests__/components/sign-up-form.test.tsx create mode 100644 __tests__/components/typography.test.tsx rename {src => __tests__}/components/ui/button.test.tsx (98%) rename {src => __tests__}/components/ui/checkbox.test.tsx (99%) rename {src => __tests__}/components/ui/input.test.tsx (98%) rename {src => __tests__}/components/ui/modal-keyboard-aware-scroll-view.test.tsx (81%) rename {src => __tests__}/components/ui/progress-bar.test.tsx (90%) rename {src => __tests__}/components/ui/select.test.tsx (98%) create mode 100644 __tests__/lib/hooks/use-is-first-time.test.tsx rename {src => __tests__}/lib/i18n/utils.test.ts (77%) create mode 100644 __tests__/lib/storage.test.tsx create mode 100644 __tests__/lib/utils.test.ts diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 71c00ceb8..b007dbe6b 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -40,5 +40,5 @@ jobs: -Dsonar.projectKey=${{ secrets.SONAR_PROJECT }} -Dsonar.sonar.sourceEncoding=UTF-8 -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info - -Dsonar.coverage.exclusions=**/node_modules/**,**/storage/**,**/**.config.js,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/icons/**,**/docs/**,**/cli/**,**/android/**,**/ios/**,env.js - -Dsonar.exclusions=**/docs/**,**/__mocks__/** + -Dsonar.coverage.exclusions=**/node_modules/**,**/storage/**,**/**.config.js,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/icons/**,**/docs/**,**/cli/**,**/android/**,**/ios/**,env.js,app.config.ts,jest-setup.ts + -Dsonar.exclusions=**/docs/**,**/__mocks__/**,**/__tests__/**,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,app.config.ts,jest-setup.ts diff --git a/__tests__/api/auth/use-forgot-password.test.tsx b/__tests__/api/auth/use-forgot-password.test.tsx new file mode 100644 index 000000000..554f33044 --- /dev/null +++ b/__tests__/api/auth/use-forgot-password.test.tsx @@ -0,0 +1,70 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { useForgotPassword } from '@/api/auth/use-forgot-password'; +import { client } from '@/api/common'; + +// Mock the client +jest.mock('@/api/common', () => ({ + client: jest.fn(), +})); + +const mockedClient = client as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('useForgotPassword', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(useForgotPassword).toBeDefined(); + }); + + it('should call client with correct parameters', async () => { + const mockResponse = { + data: { + message: 'Password reset email sent', + }, + }; + + mockedClient.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useForgotPassword(), { + wrapper: createWrapper(), + }); + + const variables = { + email: 'test@example.com', + }; + + result.current.mutate(variables); + + await waitFor(() => { + expect(mockedClient).toHaveBeenCalledWith({ + url: '/v1/users/password', + method: 'POST', + data: { + email: variables.email, + redirect_url: 'https://example.com', + }, + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + }); +}); diff --git a/__tests__/api/auth/use-login.test.tsx b/__tests__/api/auth/use-login.test.tsx new file mode 100644 index 000000000..78e0d0531 --- /dev/null +++ b/__tests__/api/auth/use-login.test.tsx @@ -0,0 +1,105 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { useLogin } from '@/api/auth/use-login'; +import { client } from '@/api/common'; + +// Mock the client +jest.mock('@/api/common', () => ({ + client: jest.fn(), +})); + +const mockedClient = client as jest.MockedFunction; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function createMockLoginResponse(overrides = {}) { + return { + data: { + id: 1, + username: 'testuser', + email: 'test@example.com', + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + ...overrides, + }, + }; +} + +function createLoginVariables(overrides = {}) { + return { + email: 'test@example.com', + password: 'password123', + ...overrides, + }; +} + +const createWrapper = () => { + const queryClient = createTestQueryClient(); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('useLogin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(useLogin).toBeDefined(); + }); + + it('should call client with correct parameters', async () => { + const mockResponse = createMockLoginResponse(); + mockedClient.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useLogin(), { + wrapper: createWrapper(), + }); + + const variables = createLoginVariables(); + result.current.mutate(variables); + + await waitFor(() => { + expect(mockedClient).toHaveBeenCalledWith({ + url: '/v1/users/sign_in', + method: 'POST', + data: { + user: variables, + }, + }); + }); + }); + + it('should handle login with expiresInMins parameter', async () => { + const mockResponse = createMockLoginResponse(); + mockedClient.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useLogin(), { + wrapper: createWrapper(), + }); + + const variables = createLoginVariables({ expiresInMins: 60 }); + result.current.mutate(variables); + + await waitFor(() => { + expect(mockedClient).toHaveBeenCalledWith({ + url: '/v1/users/sign_in', + method: 'POST', + data: { + user: variables, + }, + }); + }); + }); +}); diff --git a/__tests__/api/auth/use-sign-up.test.tsx b/__tests__/api/auth/use-sign-up.test.tsx new file mode 100644 index 000000000..b43cbc7cc --- /dev/null +++ b/__tests__/api/auth/use-sign-up.test.tsx @@ -0,0 +1,128 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { useSignUp } from '@/api/auth/use-sign-up'; +import { client } from '@/api/common'; + +// Mock the client +jest.mock('@/api/common', () => ({ + client: jest.fn(), +})); + +const mockedClient = client as jest.MockedFunction; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function createMockSignUpResponse(overrides = {}) { + return { + data: { + status: 'success', + data: { + id: '1', + email: 'test@example.com', + name: 'Test User', + provider: 'email', + uid: 'test@example.com', + allowPasswordChange: true, + ...overrides, + }, + }, + }; +} + +function createSignUpVariables(overrides = {}) { + return { + email: 'test@example.com', + name: 'Test User', + password: 'password123', + passwordConfirmation: 'password123', + ...overrides, + }; +} + +const createWrapper = () => { + const queryClient = createTestQueryClient(); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('useSignUp', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(useSignUp).toBeDefined(); + }); + + it('should call client with correct parameters', async () => { + const mockResponse = createMockSignUpResponse({ + createdAt: '2023-01-01T00:00:00Z', + updatedAt: '2023-01-01T00:00:00Z', + }); + + mockedClient.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useSignUp(), { + wrapper: createWrapper(), + }); + + const variables = createSignUpVariables(); + result.current.mutate(variables); + + await waitFor(() => { + expect(mockedClient).toHaveBeenCalledWith({ + url: '/v1/users', + method: 'POST', + data: { + user: variables, + }, + }); + }); + }); + + it('should handle sign up with all required fields', async () => { + const mockResponse = createMockSignUpResponse({ + email: 'newuser@example.com', + name: 'New User', + uid: 'newuser@example.com', + createdAt: '2023-01-01T00:00:00Z', + updatedAt: '2023-01-01T00:00:00Z', + }); + + mockedClient.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useSignUp(), { + wrapper: createWrapper(), + }); + + const variables = createSignUpVariables({ + email: 'newuser@example.com', + name: 'New User', + password: 'securepassword', + passwordConfirmation: 'securepassword', + }); + + result.current.mutate(variables); + + await waitFor(() => { + expect(mockedClient).toHaveBeenCalledWith({ + url: '/v1/users', + method: 'POST', + data: { + user: variables, + }, + }); + }); + }); +}); diff --git a/__tests__/api/auth/use-update-password.test.tsx b/__tests__/api/auth/use-update-password.test.tsx new file mode 100644 index 000000000..1fcc9923d --- /dev/null +++ b/__tests__/api/auth/use-update-password.test.tsx @@ -0,0 +1,68 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { useUpdatePassword } from '@/api/auth/use-update-password'; +import { client } from '@/api/common'; + +// Mock the client +jest.mock('@/api/common', () => ({ + client: jest.fn(), +})); + +const mockedClient = client as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('useUpdatePassword', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(useUpdatePassword).toBeDefined(); + }); + + it('should call client with correct parameters', async () => { + const mockResponse = { + data: { + message: 'Password updated successfully', + }, + }; + + mockedClient.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useUpdatePassword(), { + wrapper: createWrapper(), + }); + + const variables = { + password: 'newPassword123', + passwordConfirmation: 'newPassword123', + }; + + result.current.mutate(variables); + + await waitFor(() => { + expect(mockedClient).toHaveBeenCalledWith({ + url: '/v1/users/password', + method: 'PUT', + data: variables, + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + }); +}); diff --git a/src/api/common/interceptors.spec.ts b/__tests__/api/common/interceptors.test.ts similarity index 98% rename from src/api/common/interceptors.spec.ts rename to __tests__/api/common/interceptors.test.ts index 05264a6c3..ef286ba9e 100644 --- a/src/api/common/interceptors.spec.ts +++ b/__tests__/api/common/interceptors.test.ts @@ -3,7 +3,7 @@ import { AxiosError, AxiosHeaders } from 'axios'; import interceptors from '@/api/common/interceptors'; -import { client } from './client'; +import { client } from '../../../src/api/common/client'; const testRequestInterceptors = () => { describe('request interceptors', () => { diff --git a/src/api/common/utils.spec.tsx b/__tests__/api/common/utils.test.tsx similarity index 97% rename from src/api/common/utils.spec.tsx rename to __tests__/api/common/utils.test.tsx index 16fdf4715..72421b076 100644 --- a/src/api/common/utils.spec.tsx +++ b/__tests__/api/common/utils.test.tsx @@ -1,4 +1,4 @@ -import { getUrlParameters, toCamelCase, toSnakeCase } from './utils'; +import { getUrlParameters, toCamelCase, toSnakeCase } from '../../../src/api/common/utils'; describe('utils', () => { describe('toCamelCase', () => { diff --git a/__tests__/app/_layout.test.tsx b/__tests__/app/_layout.test.tsx new file mode 100644 index 000000000..d5320095f --- /dev/null +++ b/__tests__/app/_layout.test.tsx @@ -0,0 +1,139 @@ +import { SplashScreen } from 'expo-router'; + +import TabLayout from '@/app/(app)/_layout'; +import { useAuth } from '@/components/providers/auth'; +import { useIsFirstTime } from '@/lib/hooks/use-is-first-time'; +import { render } from '@/lib/test-utils'; + +// Mock all dependencies +jest.mock('@dev-plugins/react-query', () => ({ + useReactQueryDevTools: jest.fn(), +})); + +jest.mock('@/components/providers/auth', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('@/lib/hooks/use-is-first-time', () => ({ + useIsFirstTime: jest.fn(), +})); + +jest.mock('expo-router', () => { + const MockTabs = ({ children }: { children: React.ReactNode }) => children; + MockTabs.Screen = () => null; + + return { + Redirect: ({ href: _href }: { href: string }) => null, + Stack: { + Screen: () => null, + }, + Tabs: MockTabs, + Link: ({ children }: { children: React.ReactNode }) => children, + SplashScreen: { + hideAsync: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +jest.mock('@/components/ui/icons', () => ({ + Feed: () => null, + Settings: () => null, + Style: () => null, +})); + +const mockUseAuth = useAuth as jest.MockedFunction; +const mockUseIsFirstTime = useIsFirstTime as jest.MockedFunction< + typeof useIsFirstTime +>; +const mockSplashScreen = SplashScreen as jest.Mocked; + +const setupFirstTimeUser = () => { + mockUseIsFirstTime.mockReturnValue([true, jest.fn()]); + mockUseAuth.mockReturnValue({ + token: null, + isAuthenticated: false, + loading: false, + ready: true, + logout: jest.fn(), + }); +}; + +const setupUnauthenticatedUser = () => { + mockUseIsFirstTime.mockReturnValue([false, jest.fn()]); + mockUseAuth.mockReturnValue({ + token: null, + isAuthenticated: false, + loading: false, + ready: true, + logout: jest.fn(), + }); +}; + +const setupAuthenticatedUser = () => { + mockUseIsFirstTime.mockReturnValue([false, jest.fn()]); + mockUseAuth.mockReturnValue({ + token: 'mock-token', + isAuthenticated: true, + loading: false, + ready: true, + logout: jest.fn(), + }); +}; + +describe('TabLayout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should redirect to onboarding when user is first time', () => { + setupFirstTimeUser(); + render(); + // Since we're mocking Redirect to return null, we just test the logic flow + expect(mockUseIsFirstTime).toHaveBeenCalled(); + }); + + it('should redirect to sign-in when user is not authenticated and auth is ready', () => { + setupUnauthenticatedUser(); + render(); + expect(mockUseAuth).toHaveBeenCalled(); + }); + + it('should render tabs when user is authenticated', () => { + setupAuthenticatedUser(); + render(); + expect(mockUseAuth).toHaveBeenCalled(); + }); + + it('should hide splash screen when auth is not ready', () => { + mockUseIsFirstTime.mockReturnValue([false, jest.fn()]); + mockUseAuth.mockReturnValue({ + token: 'mock-token', + isAuthenticated: true, + loading: false, + ready: false, + logout: jest.fn(), + }); + render(); + expect(mockSplashScreen.hideAsync).toHaveBeenCalled(); + }); + + it('should not hide splash screen when auth is ready', () => { + mockUseIsFirstTime.mockReturnValue([false, jest.fn()]); + mockUseAuth.mockReturnValue({ + token: 'mock-token', + isAuthenticated: true, + loading: false, + ready: true, + logout: jest.fn(), + }); + render(); + expect(mockSplashScreen.hideAsync).not.toHaveBeenCalled(); + }); + + it('should call useAuth and useIsFirstTime hooks', () => { + setupAuthenticatedUser(); + render(); + expect(mockUseAuth).toHaveBeenCalled(); + expect(mockUseIsFirstTime).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/buttons.test.tsx b/__tests__/components/buttons.test.tsx new file mode 100644 index 000000000..b31ab75a0 --- /dev/null +++ b/__tests__/components/buttons.test.tsx @@ -0,0 +1,37 @@ +import { ActivityIndicator } from 'react-native'; + +import { Buttons } from '@/components/buttons'; +import { render, screen } from '@/lib/test-utils'; + +describe('Buttons component', () => { + it('should render the title', () => { + render(); + expect(screen.getByText('Buttons')).toBeTruthy(); + }); + + it('should render all button variants', () => { + render(); + + // Check for small buttons (there are multiple, so use getAllByText) + const smallButtons = screen.getAllByText('small'); + expect(smallButtons.length).toBeGreaterThan(0); + + // Check for main buttons + expect(screen.getByText('Default Button')).toBeTruthy(); + expect(screen.getByText('Secondary Button')).toBeTruthy(); + expect(screen.getByText('Outline Button')).toBeTruthy(); + expect(screen.getByText('Destructive Button')).toBeTruthy(); + expect(screen.getByText('Ghost Button')).toBeTruthy(); + expect(screen.getByText('Default Button Disabled')).toBeTruthy(); + expect(screen.getByText('Secondary Button Disabled')).toBeTruthy(); + }); + + it('should render loading buttons with activity indicators', () => { + const { UNSAFE_getAllByType } = render(); + + // Loading buttons show ActivityIndicator instead of text + // Look for ActivityIndicator components directly + const activityIndicators = UNSAFE_getAllByType(ActivityIndicator); + expect(activityIndicators.length).toBeGreaterThan(0); + }); +}); diff --git a/src/components/colors.test.tsx b/__tests__/components/colors.test.tsx similarity index 94% rename from src/components/colors.test.tsx rename to __tests__/components/colors.test.tsx index dbac9c61e..6a4550fd0 100644 --- a/src/components/colors.test.tsx +++ b/__tests__/components/colors.test.tsx @@ -2,7 +2,7 @@ import { render, screen, within } from '@testing-library/react-native'; import { colors } from '@/components/ui'; -import { Colors } from './colors'; +import { Colors } from '../../src/components/colors'; describe('Colors component', () => { it('should render the Title component', () => { diff --git a/__tests__/components/cover.test.tsx b/__tests__/components/cover.test.tsx new file mode 100644 index 000000000..67e50ee0e --- /dev/null +++ b/__tests__/components/cover.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@/lib/test-utils'; + +import { Cover } from '../../src/components/cover'; + +const SVG_WIDTH = 100; +const SVG_HEIGHT = 100; + +describe('Cover component', () => { + it('should render without crashing', () => { + render(); + expect(screen.getByTestId('cover-svg')).toBeTruthy(); + }); + + it('should accept custom props', () => { + render(); + const svgElement = screen.getByTestId('cover-svg'); + expect(svgElement).toBeTruthy(); + expect(svgElement.props.width).toBe(SVG_WIDTH); + expect(svgElement.props.height).toBe(SVG_HEIGHT); + }); +}); diff --git a/src/components/forgot-password-form.test.tsx b/__tests__/components/forgot-password-form.test.tsx similarity index 97% rename from src/components/forgot-password-form.test.tsx rename to __tests__/components/forgot-password-form.test.tsx index 96a254e0f..1360f9a84 100644 --- a/src/components/forgot-password-form.test.tsx +++ b/__tests__/components/forgot-password-form.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@/lib/test-utils'; import { ForgotPasswordForm, type ForgotPasswordFormProps, -} from './forgot-password-form'; +} from '../../src/components/forgot-password-form'; afterEach(() => { cleanup(); diff --git a/__tests__/components/inputs.test.tsx b/__tests__/components/inputs.test.tsx new file mode 100644 index 000000000..b191e86f6 --- /dev/null +++ b/__tests__/components/inputs.test.tsx @@ -0,0 +1,101 @@ +import { Inputs } from '@/components/inputs'; +import { fireEvent, render, screen } from '@/lib/test-utils'; + +function renderInputsComponent() { + return render(); +} + +function expectTextToBeVisible(text: string) { + expect(screen.getByText(text)).toBeTruthy(); +} + +function testCheckboxInteraction() { + const checkbox = screen.getByLabelText('accept terms of condition'); + + // Initial state should be unchecked + expect(checkbox.props.accessibilityState.checked).toBe(false); + + // Click checkbox + fireEvent.press(checkbox); + + // Should be checked after click + expect(checkbox.props.accessibilityState.checked).toBe(true); +} + +function testRadioButtonInteraction() { + const radioButton = screen.getByLabelText('radio button'); + + // Initial state should be unchecked + expect(radioButton.props.accessibilityState.checked).toBe(false); + + // Click radio button + fireEvent.press(radioButton); + + // Should be checked after click + expect(radioButton.props.accessibilityState.checked).toBe(true); +} + +function testSwitchInteraction() { + const switchElement = screen.getByLabelText('switch'); + + // Initial state should be unchecked + expect(switchElement.props.accessibilityState.checked).toBe(false); + + // Click switch + fireEvent.press(switchElement); + + // Should be checked after click + expect(switchElement.props.accessibilityState.checked).toBe(true); +} + +describe('Inputs component', () => { + it('should render the title', () => { + renderInputsComponent(); + expectTextToBeVisible('Form'); + }); + + it('should render all input types', () => { + renderInputsComponent(); + + // Check for input labels + expectTextToBeVisible('Default'); + expectTextToBeVisible('Error'); + expectTextToBeVisible('Focused'); + expectTextToBeVisible('Select'); + }); + + it('should render error message for error input', () => { + renderInputsComponent(); + expectTextToBeVisible('This is a message error'); + }); + + it('should render checkbox component', () => { + renderInputsComponent(); + expectTextToBeVisible('checkbox'); + }); + + it('should render radio button component', () => { + renderInputsComponent(); + expectTextToBeVisible('radio button'); + }); + + it('should render switch component', () => { + renderInputsComponent(); + expectTextToBeVisible('switch'); + }); + + it('should handle checkbox interaction', () => { + renderInputsComponent(); + testCheckboxInteraction(); + }); + + it('should handle radio button interaction', () => { + renderInputsComponent(); + testRadioButtonInteraction(); + }); + + it('should handle switch interaction', () => { + renderInputsComponent(); + testSwitchInteraction(); + }); +}); diff --git a/src/components/login-form.test.tsx b/__tests__/components/login-form.test.tsx similarity index 95% rename from src/components/login-form.test.tsx rename to __tests__/components/login-form.test.tsx index 8cd22fe99..95888de40 100644 --- a/src/components/login-form.test.tsx +++ b/__tests__/components/login-form.test.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen } from '@/lib/test-utils'; -import { LoginForm } from './login-form'; +import { LoginForm } from '../../src/components/login-form'; afterEach(cleanup); diff --git a/src/components/providers/auth.test.tsx b/__tests__/components/providers/auth.test.tsx similarity index 99% rename from src/components/providers/auth.test.tsx rename to __tests__/components/providers/auth.test.tsx index cd0084203..6e8429caf 100644 --- a/src/components/providers/auth.test.tsx +++ b/__tests__/components/providers/auth.test.tsx @@ -11,7 +11,7 @@ import { getTokenDetails, storeTokens, useAuth, -} from './auth'; +} from '../../../src/components/providers/auth'; // Mock MMKV Storage jest.mock('react-native-mmkv', () => { diff --git a/__tests__/components/settings/item.test.tsx b/__tests__/components/settings/item.test.tsx new file mode 100644 index 000000000..b12f9d664 --- /dev/null +++ b/__tests__/components/settings/item.test.tsx @@ -0,0 +1,44 @@ +import { Text } from '@/components/ui'; +import { fireEvent, render, screen } from '@/lib/test-utils'; + +import { Item } from '../../../src/components/settings/item'; + +describe('Item component', () => { + it('should render with text', () => { + render(); + expect(screen.getByText('Language')).toBeTruthy(); + }); + + it('should render with value', () => { + render(); + expect(screen.getByText('Language')).toBeTruthy(); + expect(screen.getByText('English')).toBeTruthy(); + }); + + it('should render with icon', () => { + const TestIcon = () => Icon; + render(} />); + expect(screen.getByTestId('test-icon')).toBeTruthy(); + }); + + it('should call onPress when pressable', () => { + const mockOnPress = jest.fn(); + render(); + + const pressable = screen.getByText('Language').parent; + fireEvent.press(pressable); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + it('should show arrow when pressable', () => { + render( {}} />); + // Arrow should be rendered when onPress is provided + expect(screen.getByText('Language')).toBeTruthy(); + }); + + it('should render correctly without onPress', () => { + render(); + expect(screen.getByText('Language')).toBeTruthy(); + }); +}); diff --git a/__tests__/components/settings/items-container.test.tsx b/__tests__/components/settings/items-container.test.tsx new file mode 100644 index 000000000..3704c14bb --- /dev/null +++ b/__tests__/components/settings/items-container.test.tsx @@ -0,0 +1,35 @@ +import { Text } from '@/components/ui'; +import { render, screen } from '@/lib/test-utils'; + +import { ItemsContainer } from '../../../src/components/settings/items-container'; + +describe('ItemsContainer component', () => { + it('should render children', () => { + render( + + Test Child + , + ); + expect(screen.getByTestId('test-child')).toBeTruthy(); + }); + + it('should render with title', () => { + render( + + Test Child + , + ); + expect(screen.getByText('General')).toBeTruthy(); + expect(screen.getByTestId('test-child')).toBeTruthy(); + }); + + it('should render without title', () => { + render( + + Test Child + , + ); + expect(screen.getByTestId('test-child')).toBeTruthy(); + expect(screen.queryByText('General')).toBeNull(); + }); +}); diff --git a/__tests__/components/sign-up-form.test.tsx b/__tests__/components/sign-up-form.test.tsx new file mode 100644 index 000000000..2a95344d4 --- /dev/null +++ b/__tests__/components/sign-up-form.test.tsx @@ -0,0 +1,151 @@ +import { SignUpForm } from '@/components/sign-up-form'; +import { cleanup, fireEvent, render, screen } from '@/lib/test-utils'; + +afterEach(cleanup); + +const SIGN_UP_BUTTON = 'sign-up-button'; +const EMAIL_INPUT = 'email-input'; +const NAME_INPUT = 'name-input'; +const PASSWORD_INPUT = 'password-input'; +const PASSWORD_CONFIRMATION_INPUT = 'password-confirmation-input'; + +function fillValidFormData() { + const emailInput = screen.getByTestId(EMAIL_INPUT); + const nameInput = screen.getByTestId(NAME_INPUT); + const passwordInput = screen.getByTestId(PASSWORD_INPUT); + const passwordConfirmationInput = screen.getByTestId( + PASSWORD_CONFIRMATION_INPUT, + ); + + fireEvent.changeText(emailInput, 'test@example.com'); + fireEvent.changeText(nameInput, 'Test User'); + fireEvent.changeText(passwordInput, 'password123'); + fireEvent.changeText(passwordConfirmationInput, 'password123'); +} + +function fillMismatchedPasswords() { + const emailInput = screen.getByTestId(EMAIL_INPUT); + const nameInput = screen.getByTestId(NAME_INPUT); + const passwordInput = screen.getByTestId(PASSWORD_INPUT); + const passwordConfirmationInput = screen.getByTestId( + PASSWORD_CONFIRMATION_INPUT, + ); + + fireEvent.changeText(emailInput, 'test@example.com'); + fireEvent.changeText(nameInput, 'Test User'); + fireEvent.changeText(passwordInput, 'password123'); + fireEvent.changeText(passwordConfirmationInput, 'different123'); +} + +function expectFormFieldsToBePresent() { + expect(screen.getByTestId(EMAIL_INPUT)).toBeTruthy(); + expect(screen.getByTestId(NAME_INPUT)).toBeTruthy(); + expect(screen.getByTestId(PASSWORD_INPUT)).toBeTruthy(); + expect(screen.getByTestId(PASSWORD_CONFIRMATION_INPUT)).toBeTruthy(); + expect(screen.getByTestId(SIGN_UP_BUTTON)).toBeTruthy(); +} + +describe('SignUpForm - rendering', () => { + it('renders correctly', async () => { + render(); + expect(await screen.findByText(/Sign up/i)).toBeOnTheScreen(); + }); + + it('should render all form fields', () => { + render(); + expectFormFieldsToBePresent(); + }); + + it('should show loading state when isPending is true', () => { + render(); + + const button = screen.getByTestId(SIGN_UP_BUTTON); + expect(button.props.accessibilityState.disabled).toBe(true); + }); +}); + +describe('SignUpForm - validation', () => { + describe('required fields', () => { + it('should display required error when values are empty', async () => { + render(); + + const button = screen.getByTestId(SIGN_UP_BUTTON); + expect(screen.queryByText(/Email is required/i)).not.toBeOnTheScreen(); + fireEvent.press(button); + + expect(await screen.findByText(/Email is required/i)).toBeOnTheScreen(); + expect(screen.getByText(/Name is required/i)).toBeOnTheScreen(); + // There are multiple "Password is required" messages, so use getAllByText + const passwordErrors = screen.getAllByText(/Password is required/i); + expect(passwordErrors.length).toBeGreaterThan(0); + }); + }); + + describe('email validation', () => { + it('should display email validation error when email is invalid', async () => { + render(); + + const button = screen.getByTestId(SIGN_UP_BUTTON); + const emailInput = screen.getByTestId(EMAIL_INPUT); + + fireEvent.changeText(emailInput, 'invalid-email'); + fireEvent.press(button); + + expect( + await screen.findByText(/Invalid Email Format/i), + ).toBeOnTheScreen(); + }); + }); + + describe('password validation', () => { + it('should display password length error when password is too short', async () => { + render(); + + const button = screen.getByTestId(SIGN_UP_BUTTON); + const passwordInput = screen.getByTestId(PASSWORD_INPUT); + + fireEvent.changeText(passwordInput, '123'); + fireEvent.press(button); + + expect( + await screen.findByText(/Password must be at least 6 characters/i), + ).toBeOnTheScreen(); + }); + + it('should display password confirmation error when passwords do not match', async () => { + render(); + + const button = screen.getByTestId(SIGN_UP_BUTTON); + fillMismatchedPasswords(); + fireEvent.press(button); + + expect( + await screen.findByText(/Passwords do not match/i), + ).toBeOnTheScreen(); + }); + }); +}); + +describe('SignUpForm - form submission', () => { + it('should call onSubmit when form is valid', async () => { + const mockOnSubmit = jest.fn(); + render(); + + const button = screen.getByTestId(SIGN_UP_BUTTON); + fillValidFormData(); + fireEvent.press(button); + + // Wait a bit for form validation + await screen.findByTestId(SIGN_UP_BUTTON); + + expect(mockOnSubmit).toHaveBeenCalledWith( + { + email: 'test@example.com', + name: 'Test User', + password: 'password123', + passwordConfirmation: 'password123', + }, + undefined, // React Hook Form passes event as second parameter + ); + }); +}); diff --git a/__tests__/components/typography.test.tsx b/__tests__/components/typography.test.tsx new file mode 100644 index 000000000..e8da56b8e --- /dev/null +++ b/__tests__/components/typography.test.tsx @@ -0,0 +1,26 @@ +import { Typography } from '@/components/typography'; +import { render, screen } from '@/lib/test-utils'; + +describe('Typography component', () => { + it('should render the title', () => { + render(); + expect(screen.getByText('Typography')).toBeTruthy(); + }); + + it('should render all heading levels', () => { + render(); + + expect(screen.getByText('H1: Lorem ipsum dolor sit')).toBeTruthy(); + expect(screen.getByText('H2: Lorem ipsum dolor sit')).toBeTruthy(); + expect(screen.getByText('H3: Lorem ipsum dolor sit')).toBeTruthy(); + expect(screen.getByText('H4: Lorem ipsum dolor sit')).toBeTruthy(); + }); + + it('should render paragraph text', () => { + render(); + + expect( + screen.getByText(/Lorem ipsum dolor sit amet consectetur/), + ).toBeTruthy(); + }); +}); diff --git a/src/components/ui/button.test.tsx b/__tests__/components/ui/button.test.tsx similarity index 98% rename from src/components/ui/button.test.tsx rename to __tests__/components/ui/button.test.tsx index 484c6a3fe..236316d62 100644 --- a/src/components/ui/button.test.tsx +++ b/__tests__/components/ui/button.test.tsx @@ -3,7 +3,7 @@ import { Text } from 'react-native'; import { cleanup, render, screen, setup } from '@/lib/test-utils'; -import { Button } from './button'; +import { Button } from '../../../src/components/ui/button'; afterEach(cleanup); diff --git a/src/components/ui/checkbox.test.tsx b/__tests__/components/ui/checkbox.test.tsx similarity index 99% rename from src/components/ui/checkbox.test.tsx rename to __tests__/components/ui/checkbox.test.tsx index 01d1cac59..c3c1d9b50 100644 --- a/src/components/ui/checkbox.test.tsx +++ b/__tests__/components/ui/checkbox.test.tsx @@ -3,7 +3,7 @@ import 'react-native'; import { cleanup, fireEvent, render, screen } from '@/lib/test-utils'; -import { Checkbox, Radio, Switch } from './checkbox'; +import { Checkbox, Radio, Switch } from '../../../src/components/ui/checkbox'; afterEach(cleanup); diff --git a/src/components/ui/input.test.tsx b/__tests__/components/ui/input.test.tsx similarity index 98% rename from src/components/ui/input.test.tsx rename to __tests__/components/ui/input.test.tsx index c3c4b9f35..01caada45 100644 --- a/src/components/ui/input.test.tsx +++ b/__tests__/components/ui/input.test.tsx @@ -3,7 +3,7 @@ import { I18nManager } from 'react-native'; import { cleanup, render, screen, setup } from '@/lib/test-utils'; -import { Input } from './input'; +import { Input } from '../../../src/components/ui/input'; afterEach(cleanup); diff --git a/src/components/ui/modal-keyboard-aware-scroll-view.test.tsx b/__tests__/components/ui/modal-keyboard-aware-scroll-view.test.tsx similarity index 81% rename from src/components/ui/modal-keyboard-aware-scroll-view.test.tsx rename to __tests__/components/ui/modal-keyboard-aware-scroll-view.test.tsx index 26b56845e..8674b0a72 100644 --- a/src/components/ui/modal-keyboard-aware-scroll-view.test.tsx +++ b/__tests__/components/ui/modal-keyboard-aware-scroll-view.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react-native'; import { View } from 'react-native'; -import BottomSheetKeyboardAwareScrollView from './modal-keyboard-aware-scroll-view'; +import BottomSheetKeyboardAwareScrollView from '../../../src/components/ui/modal-keyboard-aware-scroll-view'; describe('BottomSheetKeyboardAwareScrollView component', () => { it('renders correctly', () => { diff --git a/src/components/ui/progress-bar.test.tsx b/__tests__/components/ui/progress-bar.test.tsx similarity index 90% rename from src/components/ui/progress-bar.test.tsx rename to __tests__/components/ui/progress-bar.test.tsx index 8bb6bc721..a47e21a01 100644 --- a/src/components/ui/progress-bar.test.tsx +++ b/__tests__/components/ui/progress-bar.test.tsx @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react-native'; import { createRef } from 'react'; import { getAnimatedStyle } from 'react-native-reanimated'; -import type { ProgressBarRef } from './progress-bar'; -import { ProgressBar } from './progress-bar'; +import type { ProgressBarRef } from '../../../src/components/ui/progress-bar'; +import { ProgressBar } from '../../../src/components/ui/progress-bar'; describe('ProgressBar component', () => { const PROGRESS_BAR = 'progress-bar'; diff --git a/src/components/ui/select.test.tsx b/__tests__/components/ui/select.test.tsx similarity index 98% rename from src/components/ui/select.test.tsx rename to __tests__/components/ui/select.test.tsx index 37d5ff7cc..9d810f9c7 100644 --- a/src/components/ui/select.test.tsx +++ b/__tests__/components/ui/select.test.tsx @@ -3,7 +3,7 @@ import type { OptionType } from '@/components/ui'; import { cleanup, fireEvent, render, screen, setup } from '@/lib/test-utils'; -import { Select } from './select'; +import { Select } from '../../../src/components/ui/select'; afterEach(cleanup); diff --git a/__tests__/lib/hooks/use-is-first-time.test.tsx b/__tests__/lib/hooks/use-is-first-time.test.tsx new file mode 100644 index 000000000..16511e1a6 --- /dev/null +++ b/__tests__/lib/hooks/use-is-first-time.test.tsx @@ -0,0 +1,62 @@ +import { renderHook } from '@testing-library/react-native'; +import { useMMKVBoolean } from 'react-native-mmkv'; + +import { useIsFirstTime } from '@/lib/hooks/use-is-first-time'; + +// Mock react-native-mmkv before importing the hook +jest.mock('react-native-mmkv', () => ({ + useMMKVBoolean: jest.fn(), + MMKV: jest.fn().mockImplementation(() => ({ + getString: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + })), +})); + +// Mock the storage module +jest.mock('@/lib/storage', () => ({ + storage: { + getString: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})); + +const mockUseMMKVBoolean = useMMKVBoolean as jest.MockedFunction< + typeof useMMKVBoolean +>; + +describe('useIsFirstTime', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return true when isFirstTime is undefined', () => { + mockUseMMKVBoolean.mockReturnValue([undefined, jest.fn()]); + + const { result } = renderHook(() => useIsFirstTime()); + + expect(result.current[0]).toBe(true); + expect(typeof result.current[1]).toBe('function'); + }); + + it('should return the actual value when isFirstTime is defined', () => { + const mockSetIsFirstTime = jest.fn(); + mockUseMMKVBoolean.mockReturnValue([false, mockSetIsFirstTime]); + + const { result } = renderHook(() => useIsFirstTime()); + + expect(result.current[0]).toBe(false); + expect(result.current[1]).toBe(mockSetIsFirstTime); + }); + + it('should return true when isFirstTime is true', () => { + const mockSetIsFirstTime = jest.fn(); + mockUseMMKVBoolean.mockReturnValue([true, mockSetIsFirstTime]); + + const { result } = renderHook(() => useIsFirstTime()); + + expect(result.current[0]).toBe(true); + expect(result.current[1]).toBe(mockSetIsFirstTime); + }); +}); diff --git a/src/lib/i18n/utils.test.ts b/__tests__/lib/i18n/utils.test.ts similarity index 77% rename from src/lib/i18n/utils.test.ts rename to __tests__/lib/i18n/utils.test.ts index cbe8d5415..673be5d55 100644 --- a/src/lib/i18n/utils.test.ts +++ b/__tests__/lib/i18n/utils.test.ts @@ -1,12 +1,12 @@ import { use } from 'i18next'; import { initReactI18next } from 'react-i18next'; -import en from '../../translations/en.json'; -import { storage } from '../storage'; -import type { TxKeyPath } from './utils'; -import { getLanguage, translate } from './utils'; +import en from '../../../src/translations/en.json'; +import { storage } from '../../../src/lib/storage'; +import type { TxKeyPath } from '../../../src/lib/i18n/utils'; +import { getLanguage, translate } from '../../../src/lib/i18n/utils'; -jest.mock('../storage', () => ({ +jest.mock('../../../src/lib/storage', () => ({ storage: { getString: jest.fn().mockReturnValue('en'), }, diff --git a/__tests__/lib/storage.test.tsx b/__tests__/lib/storage.test.tsx new file mode 100644 index 000000000..756da2b00 --- /dev/null +++ b/__tests__/lib/storage.test.tsx @@ -0,0 +1,92 @@ +import { type MMKV } from 'react-native-mmkv'; + +import { getItem, removeItem, setItem, storage } from '@/lib/storage'; + +const TEST_VALUE = 123; +const TEST_NUMBER = 42; + +// Mock react-native-mmkv +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + getString: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + })), +})); + +function setupMockStorage() { + const mockStorage = storage as jest.Mocked; + jest.clearAllMocks(); + return mockStorage; +} + +describe('storage utilities', () => { + let mockStorage: jest.Mocked; + + beforeEach(() => { + mockStorage = setupMockStorage(); + }); + + describe('getItem', () => { + it('should return parsed JSON when value exists', () => { + const testData = { name: 'test', value: TEST_VALUE }; + mockStorage.getString.mockReturnValue(JSON.stringify(testData)); + + const result = getItem('test-key'); + + expect(mockStorage.getString).toHaveBeenCalledWith('test-key'); + expect(result).toEqual(testData); + }); + + it('should return null when value does not exist', () => { + mockStorage.getString.mockReturnValue(undefined); + + const result = getItem('non-existent-key'); + + expect(mockStorage.getString).toHaveBeenCalledWith('non-existent-key'); + expect(result).toBeNull(); + }); + + it('should return null when value is empty string', () => { + mockStorage.getString.mockReturnValue(''); + + const result = getItem('empty-key'); + + expect(result).toBeNull(); + }); + }); + + describe('setItem', () => { + it('should store stringified JSON', async () => { + const testData = { name: 'test', value: TEST_VALUE }; + + await setItem('test-key', testData); + + expect(mockStorage.set).toHaveBeenCalledWith( + 'test-key', + JSON.stringify(testData), + ); + }); + + it('should handle primitive values', async () => { + await setItem('string-key', 'test string'); + await setItem('number-key', TEST_NUMBER); + await setItem('boolean-key', true); + + expect(mockStorage.set).toHaveBeenCalledWith( + 'string-key', + '"test string"', + ); + expect(mockStorage.set).toHaveBeenCalledWith('number-key', '42'); + expect(mockStorage.set).toHaveBeenCalledWith('boolean-key', 'true'); + }); + }); + + describe('removeItem', () => { + it('should delete the key from storage', async () => { + await removeItem('test-key'); + + expect(mockStorage.delete).toHaveBeenCalledWith('test-key'); + }); + }); +}); diff --git a/__tests__/lib/utils.test.ts b/__tests__/lib/utils.test.ts new file mode 100644 index 000000000..768fd9be6 --- /dev/null +++ b/__tests__/lib/utils.test.ts @@ -0,0 +1,68 @@ +import { Linking } from 'react-native'; + +import { createSelectors, openLinkInBrowser } from '../../src/lib/utils'; + +// Mock React Native Linking +jest.mock('react-native', () => ({ + Linking: { + canOpenURL: jest.fn(), + openURL: jest.fn(), + }, +})); + +const mockLinking = Linking as jest.Mocked; + +describe('utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('openLinkInBrowser', () => { + it('should open URL when canOpenURL returns true', async () => { + const url = 'https://example.com'; + mockLinking.canOpenURL.mockResolvedValue(true); + + openLinkInBrowser(url); + + expect(mockLinking.canOpenURL).toHaveBeenCalledWith(url); + + // Wait for the promise to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockLinking.openURL).toHaveBeenCalledWith(url); + }); + + it('should not open URL when canOpenURL returns false', async () => { + const url = 'https://example.com'; + mockLinking.canOpenURL.mockResolvedValue(false); + + openLinkInBrowser(url); + + expect(mockLinking.canOpenURL).toHaveBeenCalledWith(url); + + // Wait for the promise to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockLinking.openURL).not.toHaveBeenCalled(); + }); + }); + + describe('createSelectors', () => { + it('should create selectors for store state', () => { + const mockStore = { + getState: () => ({ count: 0, name: 'test' }), + use: {}, + } as unknown as Parameters[0]; + + const storeWithSelectors = createSelectors(mockStore); + + expect(storeWithSelectors.use).toBeDefined(); + expect( + typeof (storeWithSelectors.use as Record).count, + ).toBe('function'); + expect( + typeof (storeWithSelectors.use as Record).name, + ).toBe('function'); + }); + }); +}); diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 218cab78a..3b40d16cb 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -65,7 +65,7 @@ export default function Settings() { - + diff --git a/src/translations/en.json b/src/translations/en.json index b7d84db4f..58d57b6eb 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -81,7 +81,7 @@ "title": "Delete account" }, "english": "English", - "generale": "General", + "general": "General", "github": "Github", "language": "Language", "links": "Links",