Skip to content

Commit 9068ce2

Browse files
authored
test(profile): add coverage for ProfileScreen and EditProfileScreen (#154)
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
1 parent fc874af commit 9068ce2

2 files changed

Lines changed: 413 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import './__mocks__/setup';
2+
import React from 'react';
3+
import renderer, { act } from 'react-test-renderer';
4+
import { Text, TextInput, TouchableOpacity, Alert } from 'react-native';
5+
6+
jest.mock('../services/api', () => ({
7+
updateProfile: jest.fn(),
8+
}));
9+
10+
jest.mock('../navigation/useAppNavigation', () => ({
11+
useRootNavigation: jest.fn(),
12+
}));
13+
14+
import EditProfileScreen from '../screens/EditProfileScreen';
15+
import { useUserStore } from '../store/userStore';
16+
import { updateProfile } from '../services/api';
17+
import { useRootNavigation } from '../navigation/useAppNavigation';
18+
19+
const mockUpdateProfile = updateProfile as jest.Mock;
20+
const mockUseRootNavigation = useRootNavigation as jest.Mock;
21+
22+
function inputByPlaceholder(
23+
tree: renderer.ReactTestRenderer,
24+
placeholder: string,
25+
): renderer.ReactTestInstance {
26+
const input = tree.root
27+
.findAllByType(TextInput)
28+
.find(i => i.props.placeholder === placeholder);
29+
if (!input) {
30+
throw new Error(
31+
`Could not find an input with placeholder "${placeholder}"`,
32+
);
33+
}
34+
return input;
35+
}
36+
37+
function buttonWithText(
38+
tree: renderer.ReactTestRenderer,
39+
label: string,
40+
): renderer.ReactTestInstance {
41+
const button = tree.root
42+
.findAllByType(TouchableOpacity)
43+
.find(node =>
44+
node.findAllByType(Text).some(text => text.props.children === label),
45+
);
46+
if (!button) {
47+
throw new Error(`Could not find a button labelled "${label}"`);
48+
}
49+
return button;
50+
}
51+
52+
const INITIAL_PROFILE = {
53+
id: 'u1',
54+
wallet: 'GABC',
55+
name: 'Old Name',
56+
bio: 'Old bio',
57+
stats: {
58+
treesPlanted: 1,
59+
plasticCollected: 0,
60+
co2Reduced: 0,
61+
},
62+
};
63+
64+
describe('EditProfileScreen', () => {
65+
let navigate: jest.Mock;
66+
let goBack: jest.Mock;
67+
let alertSpy: jest.SpyInstance;
68+
69+
beforeEach(() => {
70+
jest.clearAllMocks();
71+
72+
navigate = jest.fn();
73+
goBack = jest.fn();
74+
mockUseRootNavigation.mockReturnValue({ navigate, goBack });
75+
76+
useUserStore.getState().setProfile({ ...INITIAL_PROFILE });
77+
78+
mockUpdateProfile.mockResolvedValue({ name: 'New Name', bio: 'New bio' });
79+
80+
alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
81+
});
82+
83+
afterEach(() => {
84+
alertSpy.mockRestore();
85+
});
86+
87+
function render() {
88+
let tree!: renderer.ReactTestRenderer;
89+
act(() => {
90+
tree = renderer.create(<EditProfileScreen />);
91+
});
92+
return tree;
93+
}
94+
95+
it('initializes the inputs from the current profile', () => {
96+
const tree = render();
97+
expect(inputByPlaceholder(tree, 'Your name').props.value).toBe('Old Name');
98+
expect(
99+
inputByPlaceholder(tree, 'Tell others about yourself').props.value,
100+
).toBe('Old bio');
101+
});
102+
103+
it('updates local state when the name input changes', () => {
104+
const tree = render();
105+
act(() => {
106+
inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name');
107+
});
108+
expect(inputByPlaceholder(tree, 'Your name').props.value).toBe('New Name');
109+
});
110+
111+
it('updates local state when the bio input changes', () => {
112+
const tree = render();
113+
act(() => {
114+
inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText(
115+
'A fresh bio',
116+
);
117+
});
118+
expect(
119+
inputByPlaceholder(tree, 'Tell others about yourself').props.value,
120+
).toBe('A fresh bio');
121+
});
122+
123+
it('saves and calls api.updateProfile with the trimmed payload', async () => {
124+
const tree = render();
125+
act(() => {
126+
inputByPlaceholder(tree, 'Your name').props.onChangeText(' New Name ');
127+
});
128+
act(() => {
129+
inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText(
130+
' New bio ',
131+
);
132+
});
133+
134+
await act(async () => {
135+
await buttonWithText(tree, 'Save Changes').props.onPress();
136+
});
137+
138+
expect(mockUpdateProfile).toHaveBeenCalledTimes(1);
139+
expect(mockUpdateProfile).toHaveBeenCalledWith({
140+
name: 'New Name',
141+
bio: 'New bio',
142+
});
143+
});
144+
145+
it('omits bio from the payload when it is empty', async () => {
146+
const tree = render();
147+
act(() => {
148+
inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name');
149+
});
150+
act(() => {
151+
inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText(
152+
' ',
153+
);
154+
});
155+
156+
await act(async () => {
157+
await buttonWithText(tree, 'Save Changes').props.onPress();
158+
});
159+
160+
expect(mockUpdateProfile).toHaveBeenCalledWith({
161+
name: 'New Name',
162+
bio: undefined,
163+
});
164+
});
165+
166+
it('updates userStore.profile on success and navigates back', async () => {
167+
const tree = render();
168+
act(() => {
169+
inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name');
170+
});
171+
act(() => {
172+
inputByPlaceholder(tree, 'Tell others about yourself').props.onChangeText(
173+
'New bio',
174+
);
175+
});
176+
177+
await act(async () => {
178+
await buttonWithText(tree, 'Save Changes').props.onPress();
179+
});
180+
181+
const profile = useUserStore.getState().profile;
182+
expect(profile?.name).toBe('New Name');
183+
expect(profile?.bio).toBe('New bio');
184+
expect(goBack).toHaveBeenCalledTimes(1);
185+
});
186+
187+
it('shows an error alert and keeps the profile on API failure', async () => {
188+
mockUpdateProfile.mockRejectedValue(new Error('Boom'));
189+
190+
const tree = render();
191+
act(() => {
192+
inputByPlaceholder(tree, 'Your name').props.onChangeText('New Name');
193+
});
194+
195+
await act(async () => {
196+
await buttonWithText(tree, 'Save Changes').props.onPress();
197+
});
198+
199+
expect(Alert.alert).toHaveBeenCalledWith('Save Failed', 'Boom');
200+
expect(useUserStore.getState().profile?.name).toBe('Old Name');
201+
expect(goBack).not.toHaveBeenCalled();
202+
});
203+
204+
it('blocks save and alerts when the name is empty', async () => {
205+
const tree = render();
206+
act(() => {
207+
inputByPlaceholder(tree, 'Your name').props.onChangeText(' ');
208+
});
209+
210+
await act(async () => {
211+
await buttonWithText(tree, 'Save Changes').props.onPress();
212+
});
213+
214+
expect(mockUpdateProfile).not.toHaveBeenCalled();
215+
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Name cannot be empty');
216+
});
217+
218+
it('navigates back when Cancel is pressed', () => {
219+
const tree = render();
220+
act(() => {
221+
buttonWithText(tree, 'Cancel').props.onPress();
222+
});
223+
expect(goBack).toHaveBeenCalledTimes(1);
224+
});
225+
});

0 commit comments

Comments
 (0)