From e8391556d7276a15e6880ddebde82fde32b0a19b Mon Sep 17 00:00:00 2001 From: devOgazi Date: Sat, 29 Aug 2026 02:52:30 +0100 Subject: [PATCH] test(profile): add coverage for ProfileScreen and EditProfileScreen Add ProfileScreen.test.tsx and EditProfileScreen.test.tsx to cover profile display (name, wallet address, ImpactStats, AchievementGrid), atomic disconnect/logout that clears both stores, settings navigation, and edit persistence through api.updateProfile (success, empty-name guard, and API-failure error state). Closes #145 --- src/__tests__/EditProfileScreen.test.tsx | 225 +++++++++++++++++++++++ src/__tests__/ProfileScreen.test.tsx | 188 +++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 src/__tests__/EditProfileScreen.test.tsx create mode 100644 src/__tests__/ProfileScreen.test.tsx diff --git a/src/__tests__/EditProfileScreen.test.tsx b/src/__tests__/EditProfileScreen.test.tsx new file mode 100644 index 0000000..545a892 --- /dev/null +++ b/src/__tests__/EditProfileScreen.test.tsx @@ -0,0 +1,225 @@ +import './__mocks__/setup'; +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { Text, TextInput, TouchableOpacity, Alert } from 'react-native'; + +jest.mock('../services/api', () => ({ + updateProfile: jest.fn(), +})); + +jest.mock('../navigation/useAppNavigation', () => ({ + useRootNavigation: jest.fn(), +})); + +import EditProfileScreen from '../screens/EditProfileScreen'; +import { useUserStore } from '../store/userStore'; +import { updateProfile } from '../services/api'; +import { useRootNavigation } from '../navigation/useAppNavigation'; + +const mockUpdateProfile = updateProfile as jest.Mock; +const mockUseRootNavigation = useRootNavigation as jest.Mock; + +function inputByPlaceholder( + tree: renderer.ReactTestRenderer, + placeholder: string, +): renderer.ReactTestInstance { + const input = tree.root + .findAllByType(TextInput) + .find(i => i.props.placeholder === placeholder); + if (!input) { + throw new Error( + `Could not find an input with placeholder "${placeholder}"`, + ); + } + return input; +} + +function buttonWithText( + tree: renderer.ReactTestRenderer, + label: string, +): renderer.ReactTestInstance { + const button = tree.root + .findAllByType(TouchableOpacity) + .find(node => + node.findAllByType(Text).some(text => text.props.children === label), + ); + if (!button) { + throw new Error(`Could not find a button labelled "${label}"`); + } + return button; +} + +const INITIAL_PROFILE = { + id: 'u1', + wallet: 'GABC', + name: 'Old Name', + bio: 'Old bio', + stats: { + treesPlanted: 1, + plasticCollected: 0, + co2Reduced: 0, + }, +}; + +describe('EditProfileScreen', () => { + let navigate: jest.Mock; + let goBack: jest.Mock; + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + navigate = jest.fn(); + goBack = jest.fn(); + mockUseRootNavigation.mockReturnValue({ navigate, goBack }); + + useUserStore.getState().setProfile({ ...INITIAL_PROFILE }); + + mockUpdateProfile.mockResolvedValue({ name: 'New Name', bio: 'New bio' }); + + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + function render() { + let tree!: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create(); + }); + return tree; + } + + it('initializes the inputs from the current profile', () => { + const tree = render(); + expect(inputByPlaceholder(tree, 'Your name').props.value).toBe('Old Name'); + expect( + inputByPlaceholder(tree, 'Tell others about yourself').props.value, + ).toBe('Old bio'); + }); + + it('updates local state when the name input changes', () => { + const tree = render(); + act(() => { + inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name'); + }); + expect(inputByPlaceholder(tree, 'Your name').props.value).toBe('New Name'); + }); + + it('updates local state when the bio input changes', () => { + const tree = render(); + act(() => { + inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText( + 'A fresh bio', + ); + }); + expect( + inputByPlaceholder(tree, 'Tell others about yourself').props.value, + ).toBe('A fresh bio'); + }); + + it('saves and calls api.updateProfile with the trimmed payload', async () => { + const tree = render(); + act(() => { + inputByPlaceholder(tree, 'Your name').props.onChangeText(' New Name '); + }); + act(() => { + inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText( + ' New bio ', + ); + }); + + await act(async () => { + await buttonWithText(tree, 'Save Changes').props.onPress(); + }); + + expect(mockUpdateProfile).toHaveBeenCalledTimes(1); + expect(mockUpdateProfile).toHaveBeenCalledWith({ + name: 'New Name', + bio: 'New bio', + }); + }); + + it('omits bio from the payload when it is empty', async () => { + const tree = render(); + act(() => { + inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name'); + }); + act(() => { + inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText( + ' ', + ); + }); + + await act(async () => { + await buttonWithText(tree, 'Save Changes').props.onPress(); + }); + + expect(mockUpdateProfile).toHaveBeenCalledWith({ + name: 'New Name', + bio: undefined, + }); + }); + + it('updates userStore.profile on success and navigates back', async () => { + const tree = render(); + act(() => { + inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name'); + }); + act(() => { + inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText( + 'New bio', + ); + }); + + await act(async () => { + await buttonWithText(tree, 'Save Changes').props.onPress(); + }); + + const profile = useUserStore.getState().profile; + expect(profile?.name).toBe('New Name'); + expect(profile?.bio).toBe('New bio'); + expect(goBack).toHaveBeenCalledTimes(1); + }); + + it('shows an error alert and keeps the profile on API failure', async () => { + mockUpdateProfile.mockRejectedValue(new Error('Boom')); + + const tree = render(); + act(() => { + inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name'); + }); + + await act(async () => { + await buttonWithText(tree, 'Save Changes').props.onPress(); + }); + + expect(Alert.alert).toHaveBeenCalledWith('Save Failed', 'Boom'); + expect(useUserStore.getState().profile?.name).toBe('Old Name'); + expect(goBack).not.toHaveBeenCalled(); + }); + + it('blocks save and alerts when the name is empty', async () => { + const tree = render(); + act(() => { + inputByPlaceholder(tree, 'Your name').props.onChangeText(' '); + }); + + await act(async () => { + await buttonWithText(tree, 'Save Changes').props.onPress(); + }); + + expect(mockUpdateProfile).not.toHaveBeenCalled(); + expect(Alert.alert).toHaveBeenCalledWith('Error', 'Name cannot be empty'); + }); + + it('navigates back when Cancel is pressed', () => { + const tree = render(); + act(() => { + buttonWithText(tree, 'Cancel').props.onPress(); + }); + expect(goBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/ProfileScreen.test.tsx b/src/__tests__/ProfileScreen.test.tsx new file mode 100644 index 0000000..5a9f304 --- /dev/null +++ b/src/__tests__/ProfileScreen.test.tsx @@ -0,0 +1,188 @@ +import './__mocks__/setup'; +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { Text, TouchableOpacity } from 'react-native'; + +jest.mock('../hooks/useStellarWallet', () => ({ + useStellarWallet: jest.fn(), +})); + +jest.mock('../navigation/useAppNavigation', () => ({ + useRootNavigation: jest.fn(), +})); + +import ProfileScreen from '../screens/ProfileScreen'; +import { useUserStore } from '../store/userStore'; +import { useWalletStore } from '../store/walletStore'; +import { useRootNavigation } from '../navigation/useAppNavigation'; +import { useStellarWallet } from '../hooks/useStellarWallet'; + +const mockUseRootNavigation = useRootNavigation as jest.Mock; +const mockUseStellarWallet = useStellarWallet as jest.Mock; +const mockDisconnectWallet = jest.fn(); + +function textValues(tree: renderer.ReactTestRenderer): string[] { + return tree.root + .findAllByType(Text) + .flatMap(node => + (Array.isArray(node.props.children) + ? node.props.children + : [node.props.children] + ).map(child => + typeof child === 'string' || typeof child === 'number' + ? String(child) + : '', + ), + ) + .filter(t => t.length > 0); +} + +function buttonWithText( + tree: renderer.ReactTestRenderer, + label: string, +): renderer.ReactTestInstance { + const button = tree.root + .findAllByType(TouchableOpacity) + .find(node => + node.findAllByType(Text).some(text => text.props.children === label), + ); + if (!button) { + throw new Error(`Could not find a button labelled "${label}"`); + } + return button; +} + +const PUBLIC_KEY = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; + +function seedStores() { + useWalletStore.getState().connect(PUBLIC_KEY, 'inapp'); + useUserStore.getState().setProfile({ + id: 'u1', + wallet: PUBLIC_KEY, + name: 'Ada Lovelace', + bio: 'Building a greener world', + stats: { + treesPlanted: 12, + plasticCollected: 30, + co2Reduced: 500, + }, + }); +} + +describe('ProfileScreen', () => { + let navigate: jest.Mock; + let goBack: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + navigate = jest.fn(); + goBack = jest.fn(); + mockUseRootNavigation.mockReturnValue({ navigate, goBack }); + + mockUseStellarWallet.mockImplementation(() => ({ + disconnectWallet: mockDisconnectWallet, + })); + mockDisconnectWallet.mockImplementation(() => + useWalletStore.getState().disconnect(), + ); + + act(() => { + seedStores(); + }); + }); + + let currentTree: renderer.ReactTestRenderer | null = null; + + function render(): renderer.ReactTestRenderer { + act(() => { + currentTree = renderer.create(); + }); + return currentTree!; + } + + afterEach(() => { + act(() => { + currentTree?.unmount(); + currentTree = null; + }); + }); + + it('renders the profile name from userStore', () => { + const tree = render(); + expect(textValues(tree)).toContain('Ada Lovelace'); + }); + + it('renders the truncated wallet address from walletStore', () => { + const tree = render(); + // truncatePublicKey(PUBLIC_KEY, 6) -> "GABC...23456" + expect(textValues(tree).some(t => t.includes(PUBLIC_KEY.slice(0, 6)))).toBe( + true, + ); + expect(textValues(tree).some(t => t.includes(PUBLIC_KEY.slice(-6)))).toBe( + true, + ); + }); + + it('renders the bio when present', () => { + const tree = render(); + expect(textValues(tree)).toContain('Building a greener world'); + }); + + it('renders ImpactStats driven by profile.stats', () => { + const tree = render(); + const texts = textValues(tree); + expect(texts).toContain('12'); + expect(texts).toContain('30kg'); + expect(texts).toContain('500kg'); + }); + + it('renders AchievementGrid driven by profile.stats', () => { + const tree = render(); + const texts = textValues(tree); + expect(texts).toContain('Achievements'); + // 5 achievements earned: 2 tree, 1 plastic, 2 co2 (see achievements util). + expect(texts.some(t => t.includes('unlocked'))).toBe(true); + }); + + it('disconnect clears both the wallet store and the user store', () => { + const tree = render(); + expect(useWalletStore.getState().isConnected).toBe(true); + expect(useUserStore.getState().profile).not.toBeNull(); + + act(() => { + buttonWithText(tree, 'Disconnect & Sign Out').props.onPress(); + }); + + expect(mockDisconnectWallet).toHaveBeenCalledTimes(1); + expect(useWalletStore.getState().isConnected).toBe(false); + expect(useUserStore.getState().profile).toBeNull(); + }); + + it('navigates to EditProfile when the settings row is pressed', () => { + const tree = render(); + act(() => { + buttonWithText(tree, 'Edit Profile').props.onPress(); + }); + expect(navigate).toHaveBeenCalledWith('EditProfile'); + }); + + it('navigates to NotificationPreferences when the settings row is pressed', () => { + const tree = render(); + act(() => { + buttonWithText(tree, 'Notification Preferences').props.onPress(); + }); + expect(navigate).toHaveBeenCalledWith('NotificationPreferences'); + }); + + it('renders the EmptyState when the wallet is not connected', () => { + act(() => { + currentTree?.unmount(); + currentTree = null; + useWalletStore.getState().disconnect(); + }); + const tree = render(); + expect(textValues(tree)).toContain('No profile'); + expect(textValues(tree)).toContain('Connect a wallet to view your profile'); + }); +});